This is page 1 of 6. Use http://codebase.md/tiberriver256/azure-devops-mcp?page={x} to view the full context. # Directory Structure ``` ├── .clinerules ├── .env.example ├── .eslintrc.json ├── .github │ ├── FUNDING.yml │ ├── release-please-config.json │ ├── release-please-manifest.json │ └── workflows │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── .husky │ ├── commit-msg │ └── pre-commit ├── .kilocode │ └── mcp.json ├── .prettierrc ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── commitlint.config.js ├── CONTRIBUTING.md ├── create_branch.sh ├── docs │ ├── authentication.md │ ├── azure-identity-authentication.md │ ├── ci-setup.md │ ├── examples │ │ ├── azure-cli-authentication.env │ │ ├── azure-identity-authentication.env │ │ ├── pat-authentication.env │ │ └── README.md │ ├── testing │ │ ├── README.md │ │ └── setup.md │ └── tools │ ├── core-navigation.md │ ├── organizations.md │ ├── pipelines.md │ ├── projects.md │ ├── pull-requests.md │ ├── README.md │ ├── repositories.md │ ├── resources.md │ ├── search.md │ ├── user-tools.md │ ├── wiki.md │ └── work-items.md ├── finish_task.sh ├── jest.e2e.config.js ├── jest.int.config.js ├── jest.unit.config.js ├── LICENSE ├── memory │ └── tasks_memory_2025-05-26T16-18-03.json ├── package-lock.json ├── package.json ├── project-management │ ├── planning │ │ ├── architecture-guide.md │ │ ├── azure-identity-authentication-design.md │ │ ├── project-plan.md │ │ ├── project-structure.md │ │ ├── tech-stack.md │ │ └── the-dream-team.md │ ├── startup.xml │ ├── tdd-cycle.xml │ └── troubleshooter.xml ├── README.md ├── setup_env.sh ├── shrimp-rules.md ├── src │ ├── clients │ │ └── azure-devops.ts │ ├── features │ │ ├── organizations │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-organizations │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pipelines │ │ │ ├── get-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pipelines │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── trigger-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── types.ts │ │ ├── projects │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-project │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-project-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-projects │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pull-requests │ │ │ ├── add-pull-request-comment │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── create-pull-request │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-pull-request-comments │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pull-requests │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── types.ts │ │ │ └── update-pull-request │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-all-repositories-tree │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── feature.spec.unit.ts.snap │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-file-content │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-repositories │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── search │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── search-code │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-work-items │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── users │ │ │ ├── get-me │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── wikis │ │ │ ├── create-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── create-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wikis │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-wiki-pages │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ └── update-wiki-page │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── work-items │ │ ├── __test__ │ │ │ ├── fixtures.ts │ │ │ ├── test-helpers.ts │ │ │ └── test-utils.ts │ │ ├── create-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── get-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── index.spec.unit.ts │ │ ├── index.ts │ │ ├── list-work-items │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── manage-work-item-link │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── schemas.ts │ │ ├── tool-definitions.ts │ │ ├── types.ts │ │ └── update-work-item │ │ ├── feature.spec.int.ts │ │ ├── feature.spec.unit.ts │ │ ├── feature.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── index.spec.unit.ts │ ├── index.ts │ ├── server.spec.e2e.ts │ ├── server.ts │ ├── shared │ │ ├── api │ │ │ ├── client.ts │ │ │ └── index.ts │ │ ├── auth │ │ │ ├── auth-factory.ts │ │ │ ├── client-factory.ts │ │ │ └── index.ts │ │ ├── config │ │ │ ├── index.ts │ │ │ └── version.ts │ │ ├── enums │ │ │ ├── index.spec.unit.ts │ │ │ └── index.ts │ │ ├── errors │ │ │ ├── azure-devops-errors.ts │ │ │ ├── handle-request-error.ts │ │ │ └── index.ts │ │ ├── test │ │ │ └── test-helpers.ts │ │ └── types │ │ ├── config.ts │ │ ├── index.ts │ │ ├── request-handler.ts │ │ └── tool-definition.ts │ └── utils │ ├── environment.spec.unit.ts │ └── environment.ts ├── tasks.json ├── tests │ └── setup.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "semi": true, "trailingComma": "all", "singleQuote": true, "printWidth": 80, "tabWidth": 2, "useTabs": false, "endOfLine": "lf" } ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # TypeScript dist/ *.tsbuildinfo # Environment variables .env .env.local .env.*.local # IDE .vscode/* !.vscode/extensions.json !.vscode/settings.json .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Test coverage coverage/ # Logs logs/ *.log ``` -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- ```json { "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], "env": { "node": true, "es6": true, "jest": true }, "rules": { "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } ] }, "overrides": [ { "files": ["**/*.spec.unit.ts", "tests/**/*.ts"], "rules": { "@typescript-eslint/no-explicit-any": "off" } } ], "parserOptions": { "ecmaVersion": 2020, "sourceType": "module" }, "ignorePatterns": ["dist/**/*", "project-management/**/*"] } ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Azure DevOps MCP Server - Environment Variables # Azure DevOps Organization URL (required) # e.g., https://dev.azure.com/your-organization AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization # Authentication Method (optional, defaults to 'azure-identity') # Supported values: 'pat', 'azure-identity', 'azure-cli' # - 'pat': Personal Access Token authentication # - 'azure-identity': Azure Identity authentication (DefaultAzureCredential) # - 'azure-cli': Azure CLI authentication (AzureCliCredential) AZURE_DEVOPS_AUTH_METHOD=azure-identity # Azure DevOps Personal Access Token (required for PAT authentication) # Create one at: https://dev.azure.com/your-organization/_usersSettings/tokens # Required scopes: Code (Read & Write), Work Items (Read & Write), Build (Read & Execute), # Project and Team (Read), Graph (Read), Release (Read & Execute) AZURE_DEVOPS_PAT=your-personal-access-token # Default Project to use when not specified (optional) AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project # API Version to use (optional, defaults to latest) # AZURE_DEVOPS_API_VERSION=6.0 # Note: This server uses stdio for communication, not HTTP # The following variables are not used by the server but might be used by scripts: # Logging Level (debug, info, warn, error) LOG_LEVEL=info # Azure Identity Credentials (for service principal authentication) # Required only when using azure-identity with service principals # AZURE_TENANT_ID=your-tenant-id # AZURE_CLIENT_ID=your-client-id # AZURE_CLIENT_SECRET=your-client-secret ``` -------------------------------------------------------------------------------- /.clinerules: -------------------------------------------------------------------------------- ``` # MCP Server Development Protocol ⚠️ CRITICAL: DO NOT USE attempt_completion BEFORE TESTING ⚠️ ## Commit Rules ⚠️ MANDATORY: All git commits MUST adhere to the Conventional Commits specification (https://www.conventionalcommits.org/). Example: 'feat: implement user login' or 'fix: resolve calculation error'. ⚠️ RECOMMENDED: Use 'npm run commit' to create commit messages interactively, ensuring compliance. ## Step 1: Planning (PLAN MODE) - What problem does this tool solve? - What API/service will it use? - What are the authentication requirements? □ Standard API key □ OAuth (requires separate setup script) □ Other credentials ## Step 2: Implementation (ACT MODE) 1. Bootstrap - For web services, JavaScript integration, or Node.js environments: ```bash npx @modelcontextprotocol/create-server my-server cd my-server npm install ``` - For data science, ML workflows, or Python environments: ```bash pip install mcp # Or with uv (recommended) uv add "mcp[cli]" ``` 2. Core Implementation - Use MCP SDK - Implement comprehensive logging - TypeScript (for web/JS projects): ```typescript console.error('[Setup] Initializing server...'); console.error('[API] Request to endpoint:', endpoint); console.error('[Error] Failed with:', error); ``` - Python (for data science/ML projects): ```python import logging logging.error('[Setup] Initializing server...') logging.error(f'[API] Request to endpoint: {endpoint}') logging.error(f'[Error] Failed with: {str(error)}') ``` - Add type definitions - Handle errors with context - Implement rate limiting if needed 3. Configuration - Get credentials from user if needed - Add to MCP settings: - For TypeScript projects: ```json { "mcpServers": { "my-server": { "command": "node", "args": ["path/to/build/index.js"], "env": { "API_KEY": "key" }, "disabled": false, "autoApprove": [] } } } ``` - For Python projects: ```bash # Directly with command line mcp install server.py -v API_KEY=key # Or in settings.json { "mcpServers": { "my-server": { "command": "python", "args": ["server.py"], "env": { "API_KEY": "key" }, "disabled": false, "autoApprove": [] } } } ``` ## Step 3: Testing (BLOCKER ⛔️) <thinking> BEFORE using attempt_completion, I MUST verify: □ Have I tested EVERY tool? □ Have I confirmed success from the user for each test? □ Have I documented the test results? If ANY answer is "no", I MUST NOT use attempt_completion. </thinking> 1. Test Each Tool (REQUIRED) □ Test each tool with valid inputs □ Verify output format is correct ⚠️ DO NOT PROCEED UNTIL ALL TOOLS TESTED ## Step 4: Completion ❗ STOP AND VERIFY: □ Every tool has been tested with valid inputs □ Output format is correct for each tool Only after ALL tools have been tested can attempt_completion be used. ## Key Requirements - ✓ Must use MCP SDK - ✓ Must have comprehensive logging - ✓ Must test each tool individually - ✓ Must handle errors gracefully - ⛔️ NEVER skip testing before completion ``` -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- ```markdown # Authentication Examples This directory contains example `.env` files for different authentication methods supported by the Azure DevOps MCP Server. ## Available Examples 1. **[pat-authentication.env](./pat-authentication.env)** - Example configuration for Personal Access Token (PAT) authentication 2. **[azure-identity-authentication.env](./azure-identity-authentication.env)** - Example configuration for Azure Identity (DefaultAzureCredential) authentication 3. **[azure-cli-authentication.env](./azure-cli-authentication.env)** - Example configuration for Azure CLI authentication ## How to Use These Examples 1. Choose the authentication method that best suits your needs 2. Copy the corresponding example file to the root of your project as `.env` 3. Replace the placeholder values with your actual values 4. Start the Azure DevOps MCP Server For example: ```bash # Copy the PAT authentication example cp docs/examples/pat-authentication.env .env # Edit the .env file with your values nano .env # Start the server npm start ``` ## Additional Resources For more detailed information about authentication methods, setup instructions, and troubleshooting, refer to the [Authentication Guide](../authentication.md). ``` -------------------------------------------------------------------------------- /docs/tools/README.md: -------------------------------------------------------------------------------- ```markdown # Azure DevOps MCP Server Tools Documentation This directory contains documentation for all tools available in the Azure DevOps MCP server. Each tool is documented with examples, parameters, response formats, and error handling information. ## Navigation - [Core Navigation Tools](./core-navigation.md) - Overview of tools for navigating Azure DevOps resources - [Organizations](./organizations.md) - Tools for working with organizations - [Projects](./projects.md) - Tools for working with projects - [Repositories](./repositories.md) - Tools for working with Git repositories - [Pull Requests](./pull-requests.md) - Tools for working with pull requests - [Work Items](./work-items.md) - Tools for working with work items - [Pipelines](./pipelines.md) - Tools for working with pipelines - [Resource URIs](./resources.md) - Documentation for accessing repository content via resource URIs ## Tools by Category ### Organization Tools - [`list_organizations`](./organizations.md#list_organizations) - List all Azure DevOps organizations accessible to the user ### Project Tools - [`list_projects`](./projects.md#list_projects) - List all projects in the organization - [`get_project`](./projects.md#get_project) - Get details of a specific project ### Repository Tools - [`list_repositories`](./repositories.md#list_repositories) - List all repositories in a project - [`get_repository`](./repositories.md#get_repository) - Get details of a specific repository - [`get_repository_details`](./repositories.md#get_repository_details) - Get detailed information about a repository - [`get_file_content`](./repositories.md#get_file_content) - Get content of a file or directory from a repository ### Pull Request Tools - [`create_pull_request`](./pull-requests.md#create_pull_request) - Create a new pull request - [`list_pull_requests`](./pull-requests.md#list_pull_requests) - List pull requests in a repository - [`add_pull_request_comment`](./pull-requests.md#add_pull_request_comment) - Add a comment to a pull request - [`get_pull_request_comments`](./pull-requests.md#get_pull_request_comments) - Get comments from a pull request - [`update_pull_request`](./pull-requests.md#update_pull_request) - Update an existing pull request (title, description, status, draft state, reviewers, work items) ### Work Item Tools - [`get_work_item`](./work-items.md#get_work_item) - Retrieve a work item by ID - [`create_work_item`](./work-items.md#create_work_item) - Create a new work item - [`list_work_items`](./work-items.md#list_work_items) - List work items in a project ### Pipeline Tools - [`list_pipelines`](./pipelines.md#list_pipelines) - List all pipelines in a project - [`get_pipeline`](./pipelines.md#get_pipeline) - Get details of a specific pipeline ## Tool Structure Each tool documentation follows a consistent structure: 1. **Description**: Brief explanation of what the tool does 2. **Parameters**: Required and optional parameters with explanations 3. **Response**: Expected response format with examples 4. **Error Handling**: Potential errors and how they're handled 5. **Example Usage**: Code examples showing how to use the tool 6. **Implementation Details**: Technical details about how the tool works ## Examples Examples of using multiple tools together can be found in the [Core Navigation Tools](./core-navigation.md#common-use-cases) documentation. ``` -------------------------------------------------------------------------------- /docs/testing/README.md: -------------------------------------------------------------------------------- ```markdown # Testing Trophy Approach ## Overview This project follows the Testing Trophy approach advocated by Kent C. Dodds instead of the traditional Testing Pyramid. The Testing Trophy emphasizes tests that provide higher confidence with less maintenance cost, focusing on how users actually interact with our software.  ## Key Principles 1. **"The more your tests resemble the way your software is used, the more confidence they can give you."** - Kent C. Dodds 2. Focus on testing behavior and interfaces rather than implementation details 3. Maximize return on investment where "return" is confidence and "investment" is time 4. Use arrange/act/assert pattern for all tests 5. Co-locate tests with the code they test following Feature Sliced Design ## Test Types ### Static Analysis (The Base) - TypeScript for type checking - ESLint for code quality and consistency - Runtime type checking with Zod - Formatter (Prettier) These tools catch many issues before tests are even run and provide immediate feedback during development. ### Unit Tests (Small Layer) - Located in `*.spec.unit.ts` files - Co-located with the code they test - Focus on testing complex business logic in isolation - Minimal mocking where necessary - Run with `npm run test:unit` Unit tests should be used sparingly for complex logic that requires isolated testing. We don't aim for 100% coverage with unit tests. ### Integration Tests (Main Focus) - Located in `*.spec.int.ts` files - Co-located with the features they test - Test how modules work together - Focus on testing behavior, not implementation - Run with `npm run test:int` These provide the bulk of our test coverage and confidence. They verify that different parts of the system work together correctly. ### End-to-End Tests (Small Cap) - Located in `*.spec.e2e.ts` files - **Only exists at the server level** (e.g., `server.spec.e2e.ts`) where they use the MCP client - Test complete user flows across the entire application - Provide the highest confidence but are slower and more costly to maintain - Run with `npm run test:e2e` End-to-end tests should only be created for critical user journeys that span the entire application. They should use the MCP client from `@modelcontextprotocol/sdk` to test the server as a black box, similar to how real users would interact with it. For testing interactions with external APIs like Azure DevOps, use integration tests (`*.spec.int.ts`) instead, which are co-located with the feature implementations. ## Test File Naming Convention - `*.spec.unit.ts` - For minimal unit tests (essential logic only) - `*.spec.int.ts` - For integration tests (main focus) - `*.spec.e2e.ts` - For end-to-end tests ## Test Location We co-locate unit and integration tests with the code they're testing following Feature Sliced Design principles: ``` src/ features/ feature-name/ feature.ts feature.spec.unit.ts # Unit tests feature.spec.int.ts # Integration tests ``` E2E tests are only located at the server level since they test the full application: ``` src/ server.ts server.spec.e2e.ts # E2E tests using the MCP client ``` This way, tests stay close to the code they're testing, making it easier to: - Find tests when working on a feature - Understand the relationship between tests and code - Refactor code and tests together - Maintain consistency between implementations and tests ## The Arrange/Act/Assert Pattern All tests should follow the Arrange/Act/Assert pattern: ```typescript test('should do something', () => { // Arrange - set up the test const input = 'something'; // Act - perform the action being tested const result = doSomething(input); // Assert - check that the action had the expected result expect(result).toBe('expected output'); }); ``` ## Running Tests - Run all tests: `npm test` - Run unit tests: `npm run test:unit` - Run integration tests: `npm run test:int` - Run E2E tests: `npm run test:e2e` ## CI/CD Integration Our CI/CD pipeline runs all test levels to ensure code quality: 1. Static analysis with TypeScript and ESLint 2. Unit tests 3. Integration tests 4. End-to-end tests ## Best Practices 1. Focus on integration tests for the bulk of your test coverage 2. Write unit tests only for complex business logic 3. Avoid testing implementation details 4. Use real dependencies when possible rather than mocks 5. Keep E2E tests focused on critical user flows 6. Use the arrange/act/assert pattern consistently 7. Co-locate tests with the code they're testing ## References - [The Testing Trophy and Testing Classifications](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) by Kent C. Dodds - [Testing of Microservices](https://engineering.atspotify.com/2018/01/testing-of-microservices/) (Testing Honeycomb approach) by Spotify Engineering - [Feature Sliced Design](https://feature-sliced.design/) for co-location of tests with feature implementations ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # ℹ️ DISCUSSION: [Microsoft launched an official ADO MCP Server! 🎉🎉🎉](https://github.com/Tiberriver256/mcp-server-azure-devops/discussions/237) # Azure DevOps MCP Server A Model Context Protocol (MCP) server implementation for Azure DevOps, allowing AI assistants to interact with Azure DevOps APIs through a standardized protocol. ## Overview This server implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for Azure DevOps, enabling AI assistants like Claude to interact with Azure DevOps resources securely. The server acts as a bridge between AI models and Azure DevOps APIs, providing a standardized way to: - Access and manage projects, work items, repositories, and more - Create and update work items, branches, and pull requests - Execute common DevOps workflows through natural language - Access repository content via standardized resource URIs - Safely authenticate and interact with Azure DevOps resources ## Server Structure The server is structured around the Model Context Protocol (MCP) for communicating with AI assistants. It provides tools for interacting with Azure DevOps resources including: - Projects - Work Items - Repositories - Pull Requests - Branches - Pipelines ### Core Components - **AzureDevOpsServer**: Main server class that initializes the MCP server and registers tools - **Feature Modules**: Organized by feature area (work-items, projects, repositories, etc.) - **Request Handlers**: Each feature module provides request identification and handling functions - **Tool Handlers**: Modular functions for each Azure DevOps operation - **Configuration**: Environment-based configuration for organization URL, PAT, etc. The server uses a feature-based architecture where each feature area (like work-items, projects, repositories) is encapsulated in its own module. This makes the codebase more maintainable and easier to extend with new features. ## Getting Started ### Prerequisites - Node.js (v16+) - npm or yarn - Azure DevOps account with appropriate access - Authentication credentials (see [Authentication Guide](docs/authentication.md) for details): - Personal Access Token (PAT), or - Azure Identity credentials, or - Azure CLI login ### Running with NPX ### Usage with Claude Desktop/Cursor AI To integrate with Claude Desktop or Cursor AI, add one of the following configurations to your configuration file. #### Azure Identity Authentication Be sure you are logged in to Azure CLI with `az login` then add the following: ```json { "mcpServers": { "azureDevOps": { "command": "npx", "args": ["-y", "@tiberriver256/mcp-server-azure-devops"], "env": { "AZURE_DEVOPS_ORG_URL": "https://dev.azure.com/your-organization", "AZURE_DEVOPS_AUTH_METHOD": "azure-identity", "AZURE_DEVOPS_DEFAULT_PROJECT": "your-project-name" } } } } ``` #### Personal Access Token (PAT) Authentication ```json { "mcpServers": { "azureDevOps": { "command": "npx", "args": ["-y", "@tiberriver256/mcp-server-azure-devops"], "env": { "AZURE_DEVOPS_ORG_URL": "https://dev.azure.com/your-organization", "AZURE_DEVOPS_AUTH_METHOD": "pat", "AZURE_DEVOPS_PAT": "<YOUR_PAT>", "AZURE_DEVOPS_DEFAULT_PROJECT": "your-project-name" } } } } ``` For detailed configuration instructions and more authentication options, see the [Authentication Guide](docs/authentication.md). ## Authentication Methods This server supports multiple authentication methods for connecting to Azure DevOps APIs. For detailed setup instructions, configuration examples, and troubleshooting tips, see the [Authentication Guide](docs/authentication.md). ### Supported Authentication Methods 1. **Personal Access Token (PAT)** - Simple token-based authentication 2. **Azure Identity (DefaultAzureCredential)** - Flexible authentication using the Azure Identity SDK 3. **Azure CLI** - Authentication using your Azure CLI login Example configuration files for each authentication method are available in the [examples directory](docs/examples/). ## Environment Variables For a complete list of environment variables and their descriptions, see the [Authentication Guide](docs/authentication.md#configuration-reference). Key environment variables include: | Variable | Description | Required | Default | | ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------- | ---------------- | | `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) - case-insensitive | No | `azure-identity` | | `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | | `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | | `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | | `AZURE_DEVOPS_API_VERSION` | API version to use | No | Latest | | `AZURE_TENANT_ID` | Azure AD tenant ID (for service principals) | Only with service principals | - | | `AZURE_CLIENT_ID` | Azure AD application ID (for service principals) | Only with service principals | - | | `AZURE_CLIENT_SECRET` | Azure AD client secret (for service principals) | Only with service principals | - | | `LOG_LEVEL` | Logging level (debug, info, warn, error) | No | info | ## Troubleshooting Authentication For detailed troubleshooting information for each authentication method, see the [Authentication Guide](docs/authentication.md#troubleshooting-authentication-issues). Common issues include: - Invalid or expired credentials - Insufficient permissions - Network connectivity problems - Configuration errors ## Authentication Implementation Details For technical details about how authentication is implemented in the Azure DevOps MCP server, see the [Authentication Guide](docs/authentication.md) and the source code in the `src/auth` directory. ## Available Tools The Azure DevOps MCP server provides a variety of tools for interacting with Azure DevOps resources. For detailed documentation on each tool, please refer to the corresponding documentation. ### User Tools - `get_me`: Get details of the authenticated user (id, displayName, email) ### Organization Tools - `list_organizations`: List all accessible organizations ### Project Tools - `list_projects`: List all projects in an organization - `get_project`: Get details of a specific project - `get_project_details`: Get comprehensive details of a project including process, work item types, and teams ### Repository Tools - `list_repositories`: List all repositories in a project - `get_repository`: Get details of a specific repository - `get_repository_details`: Get detailed information about a repository including statistics and refs - `get_file_content`: Get content of a file or directory from a repository ### Work Item Tools - `get_work_item`: Retrieve a work item by ID - `create_work_item`: Create a new work item - `update_work_item`: Update an existing work item - `list_work_items`: List work items in a project - `manage_work_item_link`: Add, remove, or update links between work items ### Search Tools - `search_code`: Search for code across repositories in a project - `search_wiki`: Search for content across wiki pages in a project - `search_work_items`: Search for work items across projects in Azure DevOps ### Pipelines Tools - `list_pipelines`: List pipelines in a project - `get_pipeline`: Get details of a specific pipeline - `trigger_pipeline`: Trigger a pipeline run with customizable parameters ### Wiki Tools - `get_wikis`: List all wikis in a project - `get_wiki_page`: Get content of a specific wiki page as plain text ### Pull Request Tools - [`create_pull_request`](docs/tools/pull-requests.md#create_pull_request) - Create a new pull request - [`list_pull_requests`](docs/tools/pull-requests.md#list_pull_requests) - List pull requests in a repository - [`add_pull_request_comment`](docs/tools/pull-requests.md#add_pull_request_comment) - Add a comment to a pull request - [`get_pull_request_comments`](docs/tools/pull-requests.md#get_pull_request_comments) - Get comments from a pull request - [`update_pull_request`](docs/tools/pull-requests.md#update_pull_request) - Update an existing pull request (title, description, status, draft state, reviewers, work items) For comprehensive documentation on all tools, see the [Tools Documentation](docs/tools/). ## Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. ## Star History [](https://www.star-history.com/#tiberriver256/mcp-server-azure-devops&Date) ## License MIT ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing to Azure DevOps MCP Server We love your input! We want to make contributing to Azure DevOps MCP Server as easy and transparent as possible, whether it's: - Reporting a bug - Discussing the current state of the code - Submitting a fix - Proposing new features - Becoming a maintainer ## Development Process We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. ## Pull Requests 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (see Commit Message Guidelines below) 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request ## Development Practices This project follows Test-Driven Development practices. Each new feature should: 1. Begin with a failing test 2. Implement the minimal code to make the test pass 3. Refactor while keeping tests green ## Project Structure The server is organized into feature-specific modules: ``` src/ └── features/ ├── organizations/ ├── pipelines/ ├── projects/ ├── pull-requests/ ├── repositories/ ├── search/ ├── users/ ├── wikis/ └── work-items/ ``` Each feature module: 1. Exports its schemas, types, and individual tool functions 2. Provides an `is<FeatureName>Request` function to identify if a request is for this feature 3. Provides a `handle<FeatureName>Request` function to handle requests for this feature ### Adding a New Feature or Tool When adding a new feature or tool: 1. Create a new directory under `src/features/` if needed 2. Implement your tool functions 3. Update the feature's `index.ts` to export your functions and add them to the request handlers 4. No changes to server.ts should be needed! ## Testing ### Unit Tests Run unit tests with: ```bash npm run test:unit ``` ### Integration Tests Integration tests require a connection to a real Azure DevOps instance. To run them: 1. Ensure your `.env` file is configured with valid Azure DevOps credentials: ``` AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_PAT=your-personal-access-token AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name ``` 2. Run the integration tests: ```bash npm run test:integration ``` ### CI Environment For running tests in CI environments (like GitHub Actions), see [CI Environment Setup](docs/ci-setup.md) for instructions on configuring secrets. ## Commit Message Guidelines We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for our commit messages. This leads to more readable messages that are easy to follow when looking through the project history and enables automatic versioning and changelog generation. ### Commit Message Format Each commit message consists of a **header**, a **body**, and a **footer**. The header has a special format that includes a **type**, a **scope**, and a **subject**: ``` <type>(<scope>): <subject> <BLANK LINE> <body> <BLANK LINE> <footer> ``` The **header** is mandatory, while the **scope** of the header is optional. ### Type Must be one of the following: - **feat**: A new feature - **fix**: A bug fix - **docs**: Documentation only changes - **style**: Changes that do not affect the meaning of the code (white-space, formatting, etc) - **refactor**: A code change that neither fixes a bug nor adds a feature - **perf**: A code change that improves performance - **test**: Adding missing tests or correcting existing tests - **build**: Changes that affect the build system or external dependencies - **ci**: Changes to our CI configuration files and scripts - **chore**: Other changes that don't modify src or test files ### Subject The subject contains a succinct description of the change: - Use the imperative, present tense: "change" not "changed" nor "changes" - Don't capitalize the first letter - No period (.) at the end ### Body The body should include the motivation for the change and contrast this with previous behavior. ### Footer The footer should contain any information about **Breaking Changes** and is also the place to reference GitHub issues that this commit closes. Breaking Changes should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. ### Using the Interactive Tool To simplify the process of creating correctly formatted commit messages, we've set up a tool that will guide you through the process. Simply use: ```bash npm run commit ``` This will start an interactive prompt that will help you generate a properly formatted commit message. ## Release Process This project uses [Conventional Commits](https://www.conventionalcommits.org/) to automate versioning and changelog generation. When contributing, please follow the commit message convention. To create a commit with the correct format, use: ```bash npm run commit ``` ## Automated Release Workflow Our project uses [Release Please](https://github.com/googleapis/release-please) to automate releases based on Conventional Commits. This approach manages semantic versioning, changelog generation, and GitHub Releases creation. The workflow is automatically triggered on pushes to the `main` branch and follows this process: 1. Release Please analyzes commit messages since the last release 2. If releasable changes are detected, it creates or updates a Release PR 3. When the Release PR is merged, it: - Updates the version in package.json - Updates CHANGELOG.md with details of all changes - Creates a Git tag and GitHub Release - Publishes the package to npm ### Release PR Process 1. When commits with conventional commit messages are pushed to `main`, Release Please automatically creates a Release PR 2. The Release PR contains all the changes since the last release with proper version bump based on commit types: - `feat:` commits trigger a minor version bump - `fix:` commits trigger a patch version bump - `feat!:` or `fix!:` commits with breaking changes trigger a major version bump 3. Review the Release PR to ensure the changelog and version bump are correct 4. Merge the Release PR to trigger the actual release This automation ensures consistent and well-documented releases that accurately reflect the changes made since the previous release. ## License By contributing, you agree that your contributions will be licensed under the project's license. ``` -------------------------------------------------------------------------------- /.github/release-please-manifest.json: -------------------------------------------------------------------------------- ```json { ".": "0.1.42" } ``` -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- ```yaml github: Tiberriver256 ``` -------------------------------------------------------------------------------- /src/shared/api/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './client'; ``` -------------------------------------------------------------------------------- /src/shared/types/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './config'; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/add-pull-request-comment/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/get-pull-request-comments/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/update-pull-request/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/search/search-wiki/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; ``` -------------------------------------------------------------------------------- /src/shared/config/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './version'; ``` -------------------------------------------------------------------------------- /src/shared/errors/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './azure-devops-errors'; ``` -------------------------------------------------------------------------------- /src/features/organizations/list-organizations/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/pipelines/get-pipeline/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; export * from './schema'; ``` -------------------------------------------------------------------------------- /src/features/pipelines/list-pipelines/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; export * from './schema'; ``` -------------------------------------------------------------------------------- /src/features/projects/get-project/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/projects/list-projects/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/create-pull-request/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/list-pull-requests/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; export * from './schema'; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/list-pull-requests/schema.ts: -------------------------------------------------------------------------------- ```typescript export { ListPullRequestsSchema } from '../schemas'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-file-content/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; export * from './schema'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/repositories/list-repositories/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/users/get-me/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/work-items/create-work-item/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/work-items/get-work-item/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/work-items/list-work-items/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/work-items/update-work-item/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schema'; export * from './feature'; ``` -------------------------------------------------------------------------------- /src/features/search/search-code/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './feature'; export * from '../schemas'; ``` -------------------------------------------------------------------------------- /src/features/users/get-me/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetMeSchema } from '../schemas'; export { GetMeSchema }; ``` -------------------------------------------------------------------------------- /src/features/projects/get-project/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetProjectSchema } from '../schemas'; export { GetProjectSchema }; ``` -------------------------------------------------------------------------------- /src/features/work-items/get-work-item/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetWorkItemSchema } from '../schemas'; export { GetWorkItemSchema }; ``` -------------------------------------------------------------------------------- /src/features/projects/list-projects/schema.ts: -------------------------------------------------------------------------------- ```typescript import { ListProjectsSchema } from '../schemas'; export { ListProjectsSchema }; ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wikis/index.ts: -------------------------------------------------------------------------------- ```typescript export { getWikis } from './feature'; export { GetWikisSchema } from './schema'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetRepositorySchema } from '../schemas'; export { GetRepositorySchema }; ``` -------------------------------------------------------------------------------- /src/features/work-items/list-work-items/schema.ts: -------------------------------------------------------------------------------- ```typescript import { ListWorkItemsSchema } from '../schemas'; export { ListWorkItemsSchema }; ``` -------------------------------------------------------------------------------- /src/features/work-items/create-work-item/schema.ts: -------------------------------------------------------------------------------- ```typescript import { CreateWorkItemSchema } from '../schemas'; export { CreateWorkItemSchema }; ``` -------------------------------------------------------------------------------- /src/features/work-items/update-work-item/schema.ts: -------------------------------------------------------------------------------- ```typescript import { UpdateWorkItemSchema } from '../schemas'; export { UpdateWorkItemSchema }; ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wiki-page/index.ts: -------------------------------------------------------------------------------- ```typescript export { getWikiPage } from './feature'; export { GetWikiPageSchema } from './schema'; ``` -------------------------------------------------------------------------------- /src/features/repositories/list-repositories/schema.ts: -------------------------------------------------------------------------------- ```typescript import { ListRepositoriesSchema } from '../schemas'; export { ListRepositoriesSchema }; ``` -------------------------------------------------------------------------------- /src/shared/config/version.ts: -------------------------------------------------------------------------------- ```typescript /** * Current version of the Azure DevOps MCP server */ export const VERSION = '0.1.0'; ``` -------------------------------------------------------------------------------- /src/features/organizations/list-organizations/schema.ts: -------------------------------------------------------------------------------- ```typescript import { ListOrganizationsSchema } from '../schemas'; export { ListOrganizationsSchema }; ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetProjectDetailsSchema } from '../schemas'; export { GetProjectDetailsSchema }; ``` -------------------------------------------------------------------------------- /src/features/pull-requests/create-pull-request/schema.ts: -------------------------------------------------------------------------------- ```typescript import { CreatePullRequestSchema } from '../schemas'; export { CreatePullRequestSchema }; ``` -------------------------------------------------------------------------------- /src/features/wikis/list-wiki-pages/index.ts: -------------------------------------------------------------------------------- ```typescript export { ListWikiPagesSchema } from './schema'; export { listWikiPages } from './feature'; ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/index.ts: -------------------------------------------------------------------------------- ```typescript export { createWikiPage } from './feature'; export { CreateWikiPageSchema } from './schema'; ``` -------------------------------------------------------------------------------- /src/features/work-items/manage-work-item-link/schema.ts: -------------------------------------------------------------------------------- ```typescript import { ManageWorkItemLinkSchema } from '../schemas'; export { ManageWorkItemLinkSchema }; ``` -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- ```javascript // commitlint.config.js module.exports = { extends: ['@commitlint/config-conventional'], }; ``` -------------------------------------------------------------------------------- /src/features/pipelines/trigger-pipeline/index.ts: -------------------------------------------------------------------------------- ```typescript export { triggerPipeline } from './feature'; export { TriggerPipelineSchema } from './schema'; ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki/index.ts: -------------------------------------------------------------------------------- ```typescript export { createWiki } from './feature'; export { CreateWikiSchema, WikiType } from './schema'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetRepositoryDetailsSchema } from '../schemas'; export { GetRepositoryDetailsSchema }; ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/index.ts: -------------------------------------------------------------------------------- ```typescript export { GetProjectDetailsSchema } from './schema'; export { getProjectDetails } from './feature'; ``` -------------------------------------------------------------------------------- /src/features/work-items/manage-work-item-link/index.ts: -------------------------------------------------------------------------------- ```typescript export { manageWorkItemLink } from './feature'; export { ManageWorkItemLinkSchema } from './schema'; ``` -------------------------------------------------------------------------------- /project-management/planning/project-plan.md: -------------------------------------------------------------------------------- ```markdown # Project Plan The project plan has been moved to: https://github.com/users/Tiberriver256/projects/1 ``` -------------------------------------------------------------------------------- /src/features/wikis/update-wiki-page/index.ts: -------------------------------------------------------------------------------- ```typescript export { updateWikiPage, UpdateWikiPageOptions } from './feature'; export { UpdateWikiPageSchema } from './schema'; ``` -------------------------------------------------------------------------------- /src/features/repositories/get-file-content/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetFileContentSchema } from '../schemas'; // Export with explicit name to avoid conflicts export { GetFileContentSchema }; ``` -------------------------------------------------------------------------------- /src/features/users/schemas.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Schema for the get_me tool, which takes no parameters */ export const GetMeSchema = z.object({}).strict(); ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/schema.ts: -------------------------------------------------------------------------------- ```typescript import { GetAllRepositoriesTreeSchema } from '../schemas'; // Export with explicit name to avoid conflicts export { GetAllRepositoriesTreeSchema }; ``` -------------------------------------------------------------------------------- /src/features/organizations/schemas.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Schema for the list organizations request * Note: This is an empty schema because the operation doesn't require any parameters */ export const ListOrganizationsSchema = z.object({}); ``` -------------------------------------------------------------------------------- /src/shared/types/tool-definition.ts: -------------------------------------------------------------------------------- ```typescript import { JsonSchema7Type } from 'zod-to-json-schema'; /** * Represents a tool that can be listed in the ListTools response */ export interface ToolDefinition { name: string; description: string; inputSchema: JsonSchema7Type; } ``` -------------------------------------------------------------------------------- /project-management/tdd-cycle.xml: -------------------------------------------------------------------------------- ``` <tdd-cycle> <red>Write a failing test for new functionality.</red> <green>Implement minimal code to pass the test.</green> <refactor>Refactor code, ensuring tests pass.</refactor> <repeat>Repeat for each new test.</repeat> </tdd-cycle> ``` -------------------------------------------------------------------------------- /src/features/users/types.ts: -------------------------------------------------------------------------------- ```typescript /** * User profile information */ export interface UserProfile { /** * The ID of the user */ id: string; /** * The display name of the user */ displayName: string; /** * The email address of the user */ email: string; } ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json { "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { "*.ts": "${capture}.spec.unit.ts, ${capture}.spec.int.ts, ${capture}.spec.e2e.ts" }, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.updateImportsOnFileMove.enabled": "always" } ``` -------------------------------------------------------------------------------- /src/features/projects/types.ts: -------------------------------------------------------------------------------- ```typescript import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; /** * Options for listing projects */ export interface ListProjectsOptions { stateFilter?: number; top?: number; skip?: number; continuationToken?: number; } // Re-export TeamProject type for convenience export type { TeamProject }; ``` -------------------------------------------------------------------------------- /src/features/organizations/types.ts: -------------------------------------------------------------------------------- ```typescript /** * Organization interface */ export interface Organization { /** * The ID of the organization */ id: string; /** * The name of the organization */ name: string; /** * The URL of the organization */ url: string; } /** * Azure DevOps resource ID for token acquisition */ export const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; ``` -------------------------------------------------------------------------------- /jest.int.config.js: -------------------------------------------------------------------------------- ```javascript /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['**/*.spec.int.ts'], moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverage: false, verbose: true, testPathIgnorePatterns: ['/node_modules/', '/dist/'], setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'], }; ``` -------------------------------------------------------------------------------- /jest.unit.config.js: -------------------------------------------------------------------------------- ```javascript /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['**/*.spec.unit.ts'], moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverage: false, verbose: true, testPathIgnorePatterns: ['/node_modules/', '/dist/'], setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'], }; ``` -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- ```json { "packages": { ".": { "release-type": "node", "package-name": "@tiberriver256/mcp-server-azure-devops", "changelog-path": "CHANGELOG.md", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "draft": false, "prerelease": false } }, "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" } ``` -------------------------------------------------------------------------------- /src/features/users/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { GetMeSchema } from './schemas'; /** * List of users tools */ export const usersTools: ToolDefinition[] = [ { name: 'get_me', description: 'Get details of the authenticated user (id, displayName, email)', inputSchema: zodToJsonSchema(GetMeSchema), }, ]; ``` -------------------------------------------------------------------------------- /src/shared/auth/index.ts: -------------------------------------------------------------------------------- ```typescript /** * Authentication module for Azure DevOps * * This module provides authentication functionality for Azure DevOps API. * It supports multiple authentication methods: * - Personal Access Token (PAT) * - Azure Identity (DefaultAzureCredential) * - Azure CLI (AzureCliCredential) */ export { AuthenticationMethod, AuthConfig, createAuthClient, } from './auth-factory'; export { AzureDevOpsClient } from './client-factory'; ``` -------------------------------------------------------------------------------- /src/features/organizations/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { ListOrganizationsSchema } from './schemas'; /** * List of organizations tools */ export const organizationsTools: ToolDefinition[] = [ { name: 'list_organizations', description: 'List all Azure DevOps organizations accessible to the current authentication', inputSchema: zodToJsonSchema(ListOrganizationsSchema), }, ]; ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wikis/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../../utils/environment'; /** * Schema for listing wikis in an Azure DevOps project or organization */ export const GetWikisSchema = z.object({ organizationId: z .string() .optional() .nullable() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), projectId: z .string() .optional() .nullable() .describe(`The ID or name of the project (Default: ${defaultProject})`), }); ``` -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- ```javascript /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['**/*.spec.e2e.ts'], moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverage: false, verbose: true, testPathIgnorePatterns: ['/node_modules/', '/dist/'], setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'], testTimeout: 30000, // Longer timeout for E2E tests passWithNoTests: true, // Allow tests to pass when no tests exist yet }; ``` -------------------------------------------------------------------------------- /.kilocode/mcp.json: -------------------------------------------------------------------------------- ```json { "mcpServers": { "shrimp-task-manager": { "command": "npx", "args": [ "-y", "mcp-shrimp-task-manager" ], "env": { "DATA_DIR": "D:/mcp-server-azure-devops", "TEMPLATES_USE": "en", "ENABLE_GUI": "false" }, "alwaysAllow": [ "init_project_rules", "process_thought", "plan_task", "analyze_task", "reflect_task", "split_tasks", "execute_task", "verify_task", "get_task_detail" ] } } } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "dist", "sourceMap": true, "declaration": true, "strictNullChecks": true, "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /src/features/pipelines/list-pipelines/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject } from '../../../utils/environment'; /** * Schema for the listPipelines function */ export const ListPipelinesSchema = z.object({ // The project to list pipelines from projectId: z .string() .optional() .describe(`The ID or name of the project (Default: ${defaultProject})`), // Maximum number of pipelines to return top: z.number().optional().describe('Maximum number of pipelines to return'), // Order by field and direction orderBy: z .string() .optional() .describe('Order by field and direction (e.g., "createdDate desc")'), }); ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wiki-page/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../../utils/environment'; /** * Schema for getting a wiki page from an Azure DevOps wiki */ export const GetWikiPageSchema = z.object({ organizationId: z .string() .optional() .nullable() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), projectId: z .string() .optional() .nullable() .describe(`The ID or name of the project (Default: ${defaultProject})`), wikiId: z.string().describe('The ID or name of the wiki'), pagePath: z.string().describe('The path of the page within the wiki'), }); ``` -------------------------------------------------------------------------------- /src/features/wikis/list-wiki-pages/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../../utils/environment'; /** * Schema for listing wiki pages from an Azure DevOps wiki */ export const ListWikiPagesSchema = z.object({ organizationId: z .string() .optional() .nullable() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), projectId: z .string() .optional() .nullable() .describe(`The ID or name of the project (Default: ${defaultProject})`), wikiId: z.string().describe('The ID or name of the wiki'), }); export type ListWikiPagesOptions = z.infer<typeof ListWikiPagesSchema>; ``` -------------------------------------------------------------------------------- /src/shared/types/request-handler.ts: -------------------------------------------------------------------------------- ```typescript import { CallToolRequest, CallToolResult, } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; /** * Function type for identifying if a request belongs to a specific feature. */ export interface RequestIdentifier { (request: CallToolRequest): boolean; } /** * Function type for handling feature-specific requests. * Returns either the standard MCP CallToolResult or a simplified response structure * for backward compatibility. */ export interface RequestHandler { ( connection: WebApi, request: CallToolRequest, ): Promise< CallToolResult | { content: Array<{ type: string; text: string }> } >; } ``` -------------------------------------------------------------------------------- /src/features/pipelines/get-pipeline/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject } from '../../../utils/environment'; /** * Schema for the getPipeline function */ export const GetPipelineSchema = z.object({ // The project containing the pipeline projectId: z .string() .optional() .describe(`The ID or name of the project (Default: ${defaultProject})`), // The ID of the pipeline to retrieve pipelineId: z .number() .int() .positive() .describe('The numeric ID of the pipeline to retrieve'), // The version of the pipeline to retrieve pipelineVersion: z .number() .int() .positive() .optional() .describe( 'The version of the pipeline to retrieve (latest if not specified)', ), }); ``` -------------------------------------------------------------------------------- /src/shared/types/config.ts: -------------------------------------------------------------------------------- ```typescript import { AuthenticationMethod } from '../auth/auth-factory'; /** * Azure DevOps configuration type definition */ export interface AzureDevOpsConfig { /** * The Azure DevOps organization URL (e.g., https://dev.azure.com/organization) */ organizationUrl: string; /** * Authentication method to use (pat, azure-identity, azure-cli) * @default 'azure-identity' */ authMethod?: AuthenticationMethod; /** * Personal Access Token for authentication (required for PAT authentication) */ personalAccessToken?: string; /** * Optional default project to use when not specified */ defaultProject?: string; /** * Optional API version to use (defaults to latest) */ apiVersion?: string; } ``` -------------------------------------------------------------------------------- /src/features/search/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { SearchCodeSchema, SearchWikiSchema, SearchWorkItemsSchema, } from './schemas'; /** * List of search tools */ export const searchTools: ToolDefinition[] = [ { name: 'search_code', description: 'Search for code across repositories in a project', inputSchema: zodToJsonSchema(SearchCodeSchema), }, { name: 'search_wiki', description: 'Search for content across wiki pages in a project', inputSchema: zodToJsonSchema(SearchWikiSchema), }, { name: 'search_work_items', description: 'Search for work items across projects in Azure DevOps', inputSchema: zodToJsonSchema(SearchWorkItemsSchema), }, ]; ``` -------------------------------------------------------------------------------- /src/features/projects/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { ListProjectsSchema, GetProjectSchema, GetProjectDetailsSchema, } from './schemas'; /** * List of projects tools */ export const projectsTools: ToolDefinition[] = [ { name: 'list_projects', description: 'List all projects in an organization', inputSchema: zodToJsonSchema(ListProjectsSchema), }, { name: 'get_project', description: 'Get details of a specific project', inputSchema: zodToJsonSchema(GetProjectSchema), }, { name: 'get_project_details', description: 'Get comprehensive details of a project including process, work item types, and teams', inputSchema: zodToJsonSchema(GetProjectDetailsSchema), }, ]; ``` -------------------------------------------------------------------------------- /docs/examples/pat-authentication.env: -------------------------------------------------------------------------------- ``` # Example .env file for Personal Access Token (PAT) authentication # Replace the values with your own # Authentication method (required) AZURE_DEVOPS_AUTH_METHOD=pat # Azure DevOps organization URL (required) AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization # Personal Access Token (required for PAT authentication) # Create one at: https://dev.azure.com/your-organization/_usersSettings/tokens AZURE_DEVOPS_PAT=your-personal-access-token # Default project to use when not specified (optional) AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project # API Version to use (optional, defaults to latest) # AZURE_DEVOPS_API_VERSION=6.0 # Logging Level (optional) LOG_LEVEL=info # Note: This server uses stdio for communication with the MCP client, # not HTTP. It does not listen on a network port. ``` -------------------------------------------------------------------------------- /src/features/pipelines/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { ListPipelinesSchema } from './list-pipelines/schema'; import { GetPipelineSchema } from './get-pipeline/schema'; import { TriggerPipelineSchema } from './trigger-pipeline/schema'; /** * List of pipelines tools */ export const pipelinesTools: ToolDefinition[] = [ { name: 'list_pipelines', description: 'List pipelines in a project', inputSchema: zodToJsonSchema(ListPipelinesSchema), }, { name: 'get_pipeline', description: 'Get details of a specific pipeline', inputSchema: zodToJsonSchema(GetPipelineSchema), }, { name: 'trigger_pipeline', description: 'Trigger a pipeline run', inputSchema: zodToJsonSchema(TriggerPipelineSchema), }, ]; ``` -------------------------------------------------------------------------------- /docs/tools/user-tools.md: -------------------------------------------------------------------------------- ```markdown # Azure DevOps User Tools This document describes the user-related tools provided by the Azure DevOps MCP server. ## get_me The `get_me` tool retrieves information about the currently authenticated user. ### Input This tool doesn't require any input parameters. ```json {} ``` ### Output The tool returns the user's profile information including: - `id`: The unique identifier for the user - `displayName`: The user's display name - `email`: The user's email address #### Example Response ```json { "id": "01234567-89ab-cdef-0123-456789abcdef", "displayName": "John Doe", "email": "[email protected]" } ``` ### Error Handling The tool may return the following errors: - `AzureDevOpsAuthenticationError`: If authentication fails - `AzureDevOpsError`: For general errors when retrieving user information ``` -------------------------------------------------------------------------------- /src/features/pipelines/types.ts: -------------------------------------------------------------------------------- ```typescript // Re-export the Pipeline interface from the Azure DevOps API import { Pipeline, Run, } from 'azure-devops-node-api/interfaces/PipelinesInterfaces'; /** * Options for listing pipelines */ export interface ListPipelinesOptions { projectId: string; orderBy?: string; top?: number; continuationToken?: string; } /** * Options for getting a pipeline */ export interface GetPipelineOptions { projectId: string; organizationId?: string; pipelineId: number; pipelineVersion?: number; } /** * Options for triggering a pipeline */ export interface TriggerPipelineOptions { projectId: string; pipelineId: number; branch?: string; variables?: Record<string, { value: string; isSecret?: boolean }>; templateParameters?: Record<string, string>; stagesToSkip?: string[]; } export { Pipeline, Run }; ``` -------------------------------------------------------------------------------- /src/features/wikis/update-wiki-page/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../../utils/environment'; /** * Schema for validating wiki page update options */ export const UpdateWikiPageSchema = z.object({ organizationId: z .string() .optional() .nullable() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), projectId: z .string() .optional() .nullable() .describe(`The ID or name of the project (Default: ${defaultProject})`), wikiId: z.string().min(1).describe('The ID or name of the wiki'), pagePath: z.string().min(1).describe('Path of the wiki page to update'), content: z .string() .min(1) .describe('The new content for the wiki page in markdown format'), comment: z .string() .optional() .nullable() .describe('Optional comment for the update'), }); ``` -------------------------------------------------------------------------------- /src/features/work-items/__test__/fixtures.ts: -------------------------------------------------------------------------------- ```typescript import { WorkItem } from '../types'; /** * Standard work item fixture for tests */ export const createWorkItemFixture = ( id: number, title: string = 'Test Work Item', state: string = 'Active', assignedTo?: string, ): WorkItem => { return { id, rev: 1, fields: { 'System.Id': id, 'System.Title': title, 'System.State': state, ...(assignedTo ? { 'System.AssignedTo': assignedTo } : {}), }, url: `https://dev.azure.com/test-org/test-project/_apis/wit/workItems/${id}`, } as WorkItem; }; /** * Create a collection of work items for list tests */ export const createWorkItemsFixture = (count: number = 3): WorkItem[] => { return Array.from({ length: count }, (_, i) => createWorkItemFixture( i + 1, `Work Item ${i + 1}`, i % 2 === 0 ? 'Active' : 'Resolved', ), ); }; ``` -------------------------------------------------------------------------------- /docs/examples/azure-cli-authentication.env: -------------------------------------------------------------------------------- ``` # Example .env file for Azure CLI authentication # Replace the values with your own # Authentication method (required) AZURE_DEVOPS_AUTH_METHOD=azure-cli # Azure DevOps organization URL (required) AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization # Default project to use when not specified (optional) AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project # API Version to use (optional, defaults to latest) # AZURE_DEVOPS_API_VERSION=6.0 # Logging Level (optional) LOG_LEVEL=info # Note: This server uses stdio for communication with the MCP client, # not HTTP. It does not listen on a network port. # Note: Before using Azure CLI authentication, make sure you have: # 1. Installed the Azure CLI (https://docs.microsoft.com/cli/azure/install-azure-cli) # 2. Logged in with 'az login' # 3. Verified your account has access to the Azure DevOps organization ``` -------------------------------------------------------------------------------- /src/features/repositories/list-repositories/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError } from '../../../shared/errors'; import { ListRepositoriesOptions, GitRepository } from '../types'; /** * List repositories in a project * * @param connection The Azure DevOps WebApi connection * @param options Parameters for listing repositories * @returns Array of repositories */ export async function listRepositories( connection: WebApi, options: ListRepositoriesOptions, ): Promise<GitRepository[]> { try { const gitApi = await connection.getGitApi(); const repositories = await gitApi.getRepositories( options.projectId, options.includeLinks, ); return repositories; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/projects/list-projects/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError } from '../../../shared/errors'; import { ListProjectsOptions, TeamProject } from '../types'; /** * List all projects in the organization * * @param connection The Azure DevOps WebApi connection * @param options Optional parameters for listing projects * @returns Array of projects */ export async function listProjects( connection: WebApi, options: ListProjectsOptions = {}, ): Promise<TeamProject[]> { try { const coreApi = await connection.getCoreApi(); const projects = await coreApi.getProjects( options.stateFilter, options.top, options.skip, options.continuationToken, ); return projects; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to list projects: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/work-items/__test__/test-utils.ts: -------------------------------------------------------------------------------- ```typescript /** * Test utilities for work item tests * These utilities help reduce test execution time and improve test reliability */ /** * Times test execution to help identify slow tests * @param testName Name of the test * @param fn Test function to execute */ export async function timeTest(testName: string, fn: () => Promise<void>) { const start = performance.now(); await fn(); const end = performance.now(); const duration = end - start; if (duration > 100) { console.warn(`Test "${testName}" is slow (${duration.toFixed(2)}ms)`); } return duration; } /** * Setup function to prepare test environment * Call at beginning of test to ensure consistent setup */ export function setupTestEnvironment() { // Set any environment variables needed for tests const originalEnv = { ...process.env }; return { // Clean up function to restore environment cleanup: () => { // Restore original environment process.env = originalEnv; }, }; } ``` -------------------------------------------------------------------------------- /src/utils/environment.ts: -------------------------------------------------------------------------------- ```typescript // Load environment variables import dotenv from 'dotenv'; dotenv.config(); /** * Utility functions and constants related to environment variables. */ /** * Extract organization name from Azure DevOps organization URL */ export function getOrgNameFromUrl(url?: string): string { if (!url) return 'unknown-organization'; const devMatch = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); if (devMatch) { return devMatch[1]; } // Fallback only for Azure DevOps Server URLs if (url.includes('azure')) { const fallbackMatch = url.match(/https?:\/\/[^/]+\/([^/]+)/); return fallbackMatch ? fallbackMatch[1] : 'unknown-organization'; } return 'unknown-organization'; } /** * Default project name from environment variables */ export const defaultProject = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'no default project'; /** * Default organization name derived from the organization URL */ export const defaultOrg = getOrgNameFromUrl(process.env.AZURE_DEVOPS_ORG_URL); ``` -------------------------------------------------------------------------------- /src/features/projects/get-project/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsResourceNotFoundError, AzureDevOpsError, } from '../../../shared/errors'; import { TeamProject } from '../types'; /** * Get a project by ID or name * * @param connection The Azure DevOps WebApi connection * @param projectId The ID or name of the project * @returns The project details * @throws {AzureDevOpsResourceNotFoundError} If the project is not found */ export async function getProject( connection: WebApi, projectId: string, ): Promise<TeamProject> { try { const coreApi = await connection.getCoreApi(); const project = await coreApi.getProject(projectId); if (!project) { throw new AzureDevOpsResourceNotFoundError( `Project '${projectId}' not found`, ); } return project; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to get project: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/projects/__test__/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; /** * Creates a WebApi connection for tests with real credentials * * @returns WebApi connection */ export async function getTestConnection(): Promise<WebApi | null> { // If we have real credentials, use them const orgUrl = process.env.AZURE_DEVOPS_ORG_URL; const token = process.env.AZURE_DEVOPS_PAT; if (orgUrl && token) { const authHandler = getPersonalAccessTokenHandler(token); return new WebApi(orgUrl, authHandler); } // If we don't have credentials, return null return null; } /** * Determines if integration tests should be skipped * * @returns true if integration tests should be skipped */ export function shouldSkipIntegrationTest(): boolean { if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.AZURE_DEVOPS_PAT) { console.log( 'Skipping integration test: No real Azure DevOps connection available', ); return true; } return false; } ``` -------------------------------------------------------------------------------- /src/features/repositories/__test__/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; /** * Creates a WebApi connection for tests with real credentials * * @returns WebApi connection */ export async function getTestConnection(): Promise<WebApi | null> { // If we have real credentials, use them const orgUrl = process.env.AZURE_DEVOPS_ORG_URL; const token = process.env.AZURE_DEVOPS_PAT; if (orgUrl && token) { const authHandler = getPersonalAccessTokenHandler(token); return new WebApi(orgUrl, authHandler); } // If we don't have credentials, return null return null; } /** * Determines if integration tests should be skipped * * @returns true if integration tests should be skipped */ export function shouldSkipIntegrationTest(): boolean { if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.AZURE_DEVOPS_PAT) { console.log( 'Skipping integration test: No real Azure DevOps connection available', ); return true; } return false; } ``` -------------------------------------------------------------------------------- /src/features/work-items/__test__/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; /** * Creates a WebApi connection for tests with real credentials * * @returns WebApi connection */ export async function getTestConnection(): Promise<WebApi | null> { // If we have real credentials, use them const orgUrl = process.env.AZURE_DEVOPS_ORG_URL; const token = process.env.AZURE_DEVOPS_PAT; if (orgUrl && token) { const authHandler = getPersonalAccessTokenHandler(token); return new WebApi(orgUrl, authHandler); } // If we don't have credentials, return null return null; } /** * Determines if integration tests should be skipped * * @returns true if integration tests should be skipped */ export function shouldSkipIntegrationTest(): boolean { if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.AZURE_DEVOPS_PAT) { console.log( 'Skipping integration test: No real Azure DevOps connection available', ); return true; } return false; } ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../../utils/environment'; /** * Schema for creating a new wiki page in Azure DevOps */ export const CreateWikiPageSchema = z.object({ organizationId: z .string() .optional() .nullable() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), projectId: z .string() .optional() .nullable() .describe(`The ID or name of the project (Default: ${defaultProject})`), wikiId: z.string().min(1).describe('The ID or name of the wiki'), pagePath: z .string() .optional() .nullable() .default('/') .describe( 'Path of the wiki page to create. If the path does not exist, it will be created. Defaults to the wiki root (/). Example: /ParentPage/NewPage', ), content: z .string() .min(1) .describe('The content for the new wiki page in markdown format'), comment: z .string() .optional() .describe('Optional comment for the creation or update'), }); ``` -------------------------------------------------------------------------------- /src/features/work-items/types.ts: -------------------------------------------------------------------------------- ```typescript import { WorkItem, WorkItemReference, } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; /** * Options for listing work items */ export interface ListWorkItemsOptions { projectId: string; teamId?: string; queryId?: string; wiql?: string; top?: number; skip?: number; } /** * Options for creating a work item */ export interface CreateWorkItemOptions { title: string; description?: string; assignedTo?: string; areaPath?: string; iterationPath?: string; priority?: number; parentId?: number; additionalFields?: Record<string, string | number | boolean | null>; } /** * Options for updating a work item */ export interface UpdateWorkItemOptions { title?: string; description?: string; assignedTo?: string; areaPath?: string; iterationPath?: string; priority?: number; state?: string; additionalFields?: Record<string, string | number | boolean | null>; } // Re-export WorkItem and WorkItemReference types for convenience export type { WorkItem, WorkItemReference }; ``` -------------------------------------------------------------------------------- /src/features/work-items/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ManageWorkItemLinkSchema, GetWorkItemSchema, } from './schemas'; /** * List of work items tools */ export const workItemsTools: ToolDefinition[] = [ { name: 'list_work_items', description: 'List work items in a project', inputSchema: zodToJsonSchema(ListWorkItemsSchema), }, { name: 'get_work_item', description: 'Get details of a specific work item', inputSchema: zodToJsonSchema(GetWorkItemSchema), }, { name: 'create_work_item', description: 'Create a new work item', inputSchema: zodToJsonSchema(CreateWorkItemSchema), }, { name: 'update_work_item', description: 'Update an existing work item', inputSchema: zodToJsonSchema(UpdateWorkItemSchema), }, { name: 'manage_work_item_link', description: 'Add or remove links between work items', inputSchema: zodToJsonSchema(ManageWorkItemLinkSchema), }, ]; ``` -------------------------------------------------------------------------------- /src/features/organizations/__test__/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript import { AzureDevOpsConfig } from '../../../shared/types'; import { AuthenticationMethod } from '../../../shared/auth'; /** * Creates test configuration for Azure DevOps tests * * @returns Azure DevOps config */ export function getTestConfig(): AzureDevOpsConfig | null { // If we have real credentials, use them const orgUrl = process.env.AZURE_DEVOPS_ORG_URL; const pat = process.env.AZURE_DEVOPS_PAT; if (orgUrl && pat) { return { organizationUrl: orgUrl, authMethod: AuthenticationMethod.PersonalAccessToken, personalAccessToken: pat, defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, }; } // If we don't have credentials, return null return null; } /** * Determines if integration tests should be skipped * * @returns true if integration tests should be skipped */ export function shouldSkipIntegrationTest(): boolean { if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.AZURE_DEVOPS_PAT) { console.log( 'Skipping integration test: No real Azure DevOps connection available', ); return true; } return false; } ``` -------------------------------------------------------------------------------- /src/features/wikis/update-wiki-page/feature.ts: -------------------------------------------------------------------------------- ```typescript import * as azureDevOpsClient from '../../../clients/azure-devops'; import { UpdateWikiPageSchema } from './schema'; import { z } from 'zod'; import { defaultOrg, defaultProject } from '../../../utils/environment'; /** * Options for updating a wiki page */ export type UpdateWikiPageOptions = z.infer<typeof UpdateWikiPageSchema>; /** * Updates a wiki page in Azure DevOps * @param options - The options for updating the wiki page * @returns The updated wiki page */ export async function updateWikiPage(options: UpdateWikiPageOptions) { const validatedOptions = UpdateWikiPageSchema.parse(options); const { organizationId, projectId, wikiId, pagePath, content, comment } = validatedOptions; // Create the client const client = await azureDevOpsClient.getWikiClient({ organizationId: organizationId ?? defaultOrg, }); // Prepare the wiki page content const wikiPageContent = { content, }; // Update the wiki page const updatedPage = await client.updatePage( wikiPageContent, projectId ?? defaultProject, wikiId, pagePath, { comment: comment ?? undefined, }, ); return updatedPage; } ``` -------------------------------------------------------------------------------- /docs/examples/azure-identity-authentication.env: -------------------------------------------------------------------------------- ``` # Example .env file for Azure Identity (DefaultAzureCredential) authentication # Replace the values with your own # Authentication method (required) AZURE_DEVOPS_AUTH_METHOD=azure-identity # Azure DevOps organization URL (required) AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization # Default project to use when not specified (optional) AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project # API Version to use (optional, defaults to latest) # AZURE_DEVOPS_API_VERSION=6.0 # Azure AD tenant ID (required for service principal authentication) # AZURE_TENANT_ID=your-tenant-id # Azure AD client ID (required for service principal authentication) # AZURE_CLIENT_ID=your-client-id # Azure AD client secret (required for service principal authentication) # AZURE_CLIENT_SECRET=your-client-secret # Logging Level (optional) LOG_LEVEL=info # Note: This server uses stdio for communication with the MCP client, # not HTTP. It does not listen on a network port. # Note: When using DefaultAzureCredential, you don't need to set AZURE_TENANT_ID, # AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET if you're using other credential types # like Managed Identity or Azure CLI. ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsResourceNotFoundError, AzureDevOpsError, } from '../../../shared/errors'; import { GitRepository } from '../types'; /** * Get a repository by ID or name * * @param connection The Azure DevOps WebApi connection * @param projectId The ID or name of the project * @param repositoryId The ID or name of the repository * @returns The repository details * @throws {AzureDevOpsResourceNotFoundError} If the repository is not found */ export async function getRepository( connection: WebApi, projectId: string, repositoryId: string, ): Promise<GitRepository> { try { const gitApi = await connection.getGitApi(); const repository = await gitApi.getRepository(repositoryId, projectId); if (!repository) { throw new AzureDevOpsResourceNotFoundError( `Repository '${repositoryId}' not found in project '${projectId}'`, ); } return repository; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to get repository: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/users/index.ts: -------------------------------------------------------------------------------- ```typescript /** * Users feature module * * This module contains user-related functionality. */ export * from './types'; export * from './get-me'; // Export tool definitions export * from './tool-definitions'; // New exports for request handling import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; import { RequestIdentifier, RequestHandler, } from '../../shared/types/request-handler'; import { getMe } from './'; /** * Checks if the request is for the users feature */ export const isUsersRequest: RequestIdentifier = ( request: CallToolRequest, ): boolean => { const toolName = request.params.name; return ['get_me'].includes(toolName); }; /** * Handles users feature requests */ export const handleUsersRequest: RequestHandler = async ( connection: WebApi, request: CallToolRequest, ): Promise<{ content: Array<{ type: string; text: string }> }> => { switch (request.params.name) { case 'get_me': { const result = await getMe(connection); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } default: throw new Error(`Unknown users tool: ${request.params.name}`); } }; ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wikis/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getWikis } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getWikis integration', () => { let connection: WebApi | null = null; let projectName: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test('should retrieve wikis from Azure DevOps', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get the wikis const result = await getWikis(connection, { projectId: projectName, }); // Verify the result expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); if (result.length > 0) { expect(result[0].name).toBeDefined(); expect(result[0].id).toBeDefined(); expect(result[0].type).toBeDefined(); } }); }); ``` -------------------------------------------------------------------------------- /src/features/users/get-me/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getMe } from '../get-me'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getMe Integration', () => { let connection: WebApi | null = null; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); }); test('should get authenticated user profile information', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping getMe integration test - no connection available'); return; } // Act - make a direct API call using Axios const result = await getMe(connection); // Assert on the actual response expect(result).toBeDefined(); expect(result.id).toBeDefined(); expect(typeof result.id).toBe('string'); expect(result.displayName).toBeDefined(); expect(typeof result.displayName).toBe('string'); expect(result.displayName.length).toBeGreaterThan(0); // Email should be defined, a string, and not empty expect(result.email).toBeDefined(); expect(typeof result.email).toBe('string'); expect(result.email.length).toBeGreaterThan(0); }); }); ``` -------------------------------------------------------------------------------- /src/features/work-items/get-work-item/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { getWorkItem } from './feature'; import { AzureDevOpsError } from '../../../shared/errors'; // Unit tests should only focus on isolated logic // No real connections, HTTP requests, or dependencies describe('getWorkItem unit', () => { // Unit test for error handling logic - the only part that's suitable for a unit test test('should propagate custom errors when thrown internally', async () => { // Arrange - for unit test, we mock only what's needed const mockConnection: any = { getWorkItemTrackingApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( AzureDevOpsError, ); await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( 'Custom error', ); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: any = { getWorkItemTrackingApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( 'Failed to get work item: Unexpected error', ); }); }); ``` -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: pull_request: branches: [main] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Use latest Node LTS uses: actions/setup-node@v3 with: node-version: 'lts/*' - name: Install Dependencies run: npm install - name: Lint run: npm run lint - name: Build run: npm run build - name: Unit Tests run: npm run test:unit - name: Integration Tests run: npm run test:int env: CI: 'true' AZURE_DEVOPS_ORG_URL: ${{ secrets.AZURE_DEVOPS_ORG_URL }} AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }} AZURE_DEVOPS_DEFAULT_PROJECT: ${{ secrets.AZURE_DEVOPS_DEFAULT_PROJECT }} AZURE_DEVOPS_DEFAULT_REPOSITORY: eShopOnWeb AZURE_DEVOPS_AUTH_METHOD: pat - name: E2E Tests run: npm run test:e2e env: CI: 'true' AZURE_DEVOPS_ORG_URL: ${{ secrets.AZURE_DEVOPS_ORG_URL }} AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }} AZURE_DEVOPS_DEFAULT_PROJECT: ${{ secrets.AZURE_DEVOPS_DEFAULT_PROJECT }} AZURE_DEVOPS_DEFAULT_REPOSITORY: eShopOnWeb AZURE_DEVOPS_AUTH_METHOD: pat ``` -------------------------------------------------------------------------------- /src/features/pipelines/trigger-pipeline/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject } from '../../../utils/environment'; /** * Schema for the triggerPipeline function */ export const TriggerPipelineSchema = z.object({ // The project containing the pipeline projectId: z .string() .optional() .describe(`The ID or name of the project (Default: ${defaultProject})`), // The ID of the pipeline to trigger pipelineId: z .number() .int() .positive() .describe('The numeric ID of the pipeline to trigger'), // The branch to run the pipeline on branch: z .string() .optional() .describe( 'The branch to run the pipeline on (e.g., "main", "feature/my-branch"). If left empty, the default branch will be used', ), // Variables to pass to the pipeline run variables: z .record( z.object({ value: z.string(), isSecret: z.boolean().optional(), }), ) .optional() .describe('Variables to pass to the pipeline run'), // Parameters for template-based pipelines templateParameters: z .record(z.string()) .optional() .describe('Parameters for template-based pipelines'), // Stages to skip in the pipeline run stagesToSkip: z .array(z.string()) .optional() .describe('Stages to skip in the pipeline run'), }); ``` -------------------------------------------------------------------------------- /src/features/pull-requests/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { CreatePullRequestSchema, ListPullRequestsSchema, GetPullRequestCommentsSchema, AddPullRequestCommentSchema, UpdatePullRequestSchema, } from './schemas'; /** * List of pull requests tools */ export const pullRequestsTools: ToolDefinition[] = [ { name: 'create_pull_request', description: 'Create a new pull request', inputSchema: zodToJsonSchema(CreatePullRequestSchema), }, { name: 'list_pull_requests', description: 'List pull requests in a repository', inputSchema: zodToJsonSchema(ListPullRequestsSchema), }, { name: 'get_pull_request_comments', description: 'Get comments from a specific pull request', inputSchema: zodToJsonSchema(GetPullRequestCommentsSchema), }, { name: 'add_pull_request_comment', description: 'Add a comment to a pull request (reply to existing comments or create new threads)', inputSchema: zodToJsonSchema(AddPullRequestCommentSchema), }, { name: 'update_pull_request', description: 'Update an existing pull request with new properties, link work items, and manage reviewers', inputSchema: zodToJsonSchema(UpdatePullRequestSchema), }, ]; ``` -------------------------------------------------------------------------------- /src/features/repositories/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { GetRepositorySchema, GetRepositoryDetailsSchema, ListRepositoriesSchema, GetFileContentSchema, GetAllRepositoriesTreeSchema, } from './schemas'; /** * List of repositories tools */ export const repositoriesTools: ToolDefinition[] = [ { name: 'get_repository', description: 'Get details of a specific repository', inputSchema: zodToJsonSchema(GetRepositorySchema), }, { name: 'get_repository_details', description: 'Get detailed information about a repository including statistics and refs', inputSchema: zodToJsonSchema(GetRepositoryDetailsSchema), }, { name: 'list_repositories', description: 'List repositories in a project', inputSchema: zodToJsonSchema(ListRepositoriesSchema), }, { name: 'get_file_content', description: 'Get content of a file or directory from a repository', inputSchema: zodToJsonSchema(GetFileContentSchema), }, { name: 'get_all_repositories_tree', description: 'Displays a hierarchical tree view of files and directories across multiple Azure DevOps repositories within a project, based on their default branches', inputSchema: zodToJsonSchema(GetAllRepositoriesTreeSchema), }, ]; ``` -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- ```yaml name: Release Please on: push: branches: - main permissions: contents: write pull-requests: write issues: write jobs: release-please: runs-on: ubuntu-latest steps: - uses: google-github-actions/release-please-action@v4 id: release with: config-file: .github/release-please-config.json manifest-file: .github/release-please-manifest.json # The following steps only run if a new release is created - name: Checkout code if: ${{ steps.release.outputs.release_created }} uses: actions/checkout@v3 with: ref: ${{ steps.release.outputs.tag_name }} - name: Setup Node.js if: ${{ steps.release.outputs.release_created }} uses: actions/setup-node@v3 with: node-version: 'lts/*' registry-url: 'https://registry.npmjs.org/' - name: Install Dependencies if: ${{ steps.release.outputs.release_created }} run: npm ci - name: Build package if: ${{ steps.release.outputs.release_created }} run: npm run build - name: Publish to npm if: ${{ steps.release.outputs.release_created }} run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` -------------------------------------------------------------------------------- /src/features/wikis/tool-definitions.ts: -------------------------------------------------------------------------------- ```typescript import { zodToJsonSchema } from 'zod-to-json-schema'; import { ToolDefinition } from '../../shared/types/tool-definition'; import { GetWikisSchema } from './get-wikis/schema'; import { GetWikiPageSchema } from './get-wiki-page/schema'; import { CreateWikiSchema } from './create-wiki/schema'; import { UpdateWikiPageSchema } from './update-wiki-page/schema'; import { ListWikiPagesSchema } from './list-wiki-pages/schema'; import { CreateWikiPageSchema } from './create-wiki-page/schema'; /** * List of wikis tools */ export const wikisTools: ToolDefinition[] = [ { name: 'get_wikis', description: 'Get details of wikis in a project', inputSchema: zodToJsonSchema(GetWikisSchema), }, { name: 'get_wiki_page', description: 'Get the content of a wiki page', inputSchema: zodToJsonSchema(GetWikiPageSchema), }, { name: 'create_wiki', description: 'Create a new wiki in the project', inputSchema: zodToJsonSchema(CreateWikiSchema), }, { name: 'update_wiki_page', description: 'Update content of a wiki page', inputSchema: zodToJsonSchema(UpdateWikiPageSchema), }, { name: 'list_wiki_pages', description: 'List pages within an Azure DevOps wiki', inputSchema: zodToJsonSchema(ListWikiPagesSchema), }, { name: 'create_wiki_page', description: 'Create a new page in a wiki. If the page already exists at the specified path, it will be updated.', inputSchema: zodToJsonSchema(CreateWikiPageSchema), }, ]; ``` -------------------------------------------------------------------------------- /src/features/organizations/list-organizations/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { listOrganizations } from './feature'; import { getTestConfig, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('listOrganizations integration', () => { test('should list organizations accessible to the authenticated user', async () => { // Skip if no credentials are available if (shouldSkipIntegrationTest()) { return; } // Get test configuration const config = getTestConfig(); if (!config) { throw new Error( 'Configuration should be available when test is not skipped', ); } // Act - make an actual API call to Azure DevOps const result = await listOrganizations(config); // Assert on the actual response expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); expect(result.length).toBeGreaterThan(0); // Check structure of returned organizations const firstOrg = result[0]; expect(firstOrg.id).toBeDefined(); expect(firstOrg.name).toBeDefined(); expect(firstOrg.url).toBeDefined(); // The organization URL in the config should match one of the returned organizations // Extract the organization name from the URL const orgUrlParts = config.organizationUrl.split('/'); const configOrgName = orgUrlParts[orgUrlParts.length - 1]; // Find matching organization const matchingOrg = result.find( (org) => org.name.toLowerCase() === configOrgName.toLowerCase(), ); expect(matchingOrg).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /src/features/repositories/types.ts: -------------------------------------------------------------------------------- ```typescript import { GitRepository, GitBranchStats, GitRef, GitItem, } from 'azure-devops-node-api/interfaces/GitInterfaces'; /** * Options for listing repositories */ export interface ListRepositoriesOptions { projectId: string; includeLinks?: boolean; } /** * Options for getting repository details */ export interface GetRepositoryDetailsOptions { projectId: string; repositoryId: string; includeStatistics?: boolean; includeRefs?: boolean; refFilter?: string; branchName?: string; } /** * Repository details response */ export interface RepositoryDetails { repository: GitRepository; statistics?: { branches: GitBranchStats[]; }; refs?: { value: GitRef[]; count: number; }; } /** * Options for getting all repositories tree */ export interface GetAllRepositoriesTreeOptions { organizationId: string; projectId: string; repositoryPattern?: string; depth?: number; pattern?: string; } /** * Repository tree item representation for output */ export interface RepositoryTreeItem { name: string; path: string; isFolder: boolean; level: number; } /** * Repository tree response for a single repository */ export interface RepositoryTreeResponse { name: string; tree: RepositoryTreeItem[]; stats: { directories: number; files: number; }; error?: string; } /** * Complete all repositories tree response */ export interface AllRepositoriesTreeResponse { repositories: RepositoryTreeResponse[]; } // Re-export GitRepository type for convenience export type { GitRepository, GitBranchStats, GitRef, GitItem }; ``` -------------------------------------------------------------------------------- /src/features/pipelines/list-pipelines/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { listPipelines } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '../../../shared/test/test-helpers'; describe('listPipelines integration', () => { let connection: WebApi | null = null; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); // TODO: Implement createPipeline functionality and create test pipelines here // Currently there is no way to create pipelines, so we can't ensure data exists like in list-work-items tests // In the future, we should add code similar to list-work-items to create test pipelines }); it('should list pipelines in a project', async () => { // Skip if no connection is available or no project specified if ( shouldSkipIntegrationTest() || !connection || !process.env.AZURE_DEVOPS_DEFAULT_PROJECT ) { console.log( 'Skipping listPipelines integration test - no connection or project available', ); return; } const projectId = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; const pipelines = await listPipelines(connection, { projectId }); expect(Array.isArray(pipelines)).toBe(true); // If there are pipelines, check their structure if (pipelines.length > 0) { const pipeline = pipelines[0]; expect(pipeline.id).toBeDefined(); expect(pipeline.name).toBeDefined(); expect(pipeline.folder).toBeDefined(); expect(pipeline.revision).toBeDefined(); expect(pipeline.url).toBeDefined(); } }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki/schema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../../utils/environment'; /** * Wiki types for creating wiki */ export enum WikiType { /** * The wiki is published from a git repository */ CodeWiki = 'codeWiki', /** * The wiki is provisioned for the team project */ ProjectWiki = 'projectWiki', } /** * Schema for creating a wiki in an Azure DevOps project */ export const CreateWikiSchema = z .object({ organizationId: z .string() .optional() .nullable() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), projectId: z .string() .optional() .nullable() .describe(`The ID or name of the project (Default: ${defaultProject})`), name: z.string().describe('The name of the new wiki'), type: z .nativeEnum(WikiType) .optional() .default(WikiType.ProjectWiki) .describe('Type of wiki to create (projectWiki or codeWiki)'), repositoryId: z .string() .optional() .nullable() .describe( 'The ID of the repository to associate with the wiki (required for codeWiki)', ), mappedPath: z .string() .optional() .nullable() .default('/') .describe( 'Folder path inside repository which is shown as Wiki (only for codeWiki)', ), }) .refine( (data) => { // If type is codeWiki, then repositoryId is required return data.type !== WikiType.CodeWiki || !!data.repositoryId; }, { message: 'repositoryId is required when type is codeWiki', path: ['repositoryId'], }, ); ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wiki-page/feature.ts: -------------------------------------------------------------------------------- ```typescript import * as azureDevOpsClient from '../../../clients/azure-devops'; import { AzureDevOpsError } from '../../../shared/errors/azure-devops-errors'; /** * Options for getting a wiki page */ export interface GetWikiPageOptions { /** * The ID or name of the organization * If not provided, the default organization will be used */ organizationId: string; /** * The ID or name of the project * If not provided, the default project will be used */ projectId: string; /** * The ID or name of the wiki */ wikiId: string; /** * The path of the page within the wiki */ pagePath: string; } /** * Get a wiki page from a wiki * * @param options Options for getting a wiki page * @returns Wiki page content as text/plain * @throws {AzureDevOpsResourceNotFoundError} When the wiki page is not found * @throws {AzureDevOpsPermissionError} When the user does not have permission to access the wiki page * @throws {AzureDevOpsError} When an error occurs while fetching the wiki page */ export async function getWikiPage( options: GetWikiPageOptions, ): Promise<string> { const { organizationId, projectId, wikiId, pagePath } = options; try { // Create the client const client = await azureDevOpsClient.getWikiClient({ organizationId, }); // Get the wiki page return (await client.getPage(projectId, wikiId, pagePath)).content; } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Otherwise wrap it in an AzureDevOpsError throw new AzureDevOpsError('Failed to get wiki page', { cause: error }); } } ``` -------------------------------------------------------------------------------- /src/shared/test/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; import { AzureDevOpsConfig } from '../types'; import { AuthenticationMethod } from '../auth'; /** * Creates a WebApi connection for tests with real credentials * * @returns WebApi connection */ export async function getTestConnection(): Promise<WebApi | null> { // If we have real credentials, use them const orgUrl = process.env.AZURE_DEVOPS_ORG_URL; const token = process.env.AZURE_DEVOPS_PAT; if (orgUrl && token) { const authHandler = getPersonalAccessTokenHandler(token); return new WebApi(orgUrl, authHandler); } // If we don't have credentials, return null return null; } /** * Creates test configuration for Azure DevOps tests * * @returns Azure DevOps config */ export function getTestConfig(): AzureDevOpsConfig | null { // If we have real credentials, use them const orgUrl = process.env.AZURE_DEVOPS_ORG_URL; const pat = process.env.AZURE_DEVOPS_PAT; if (orgUrl && pat) { return { organizationUrl: orgUrl, authMethod: AuthenticationMethod.PersonalAccessToken, personalAccessToken: pat, defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, }; } // If we don't have credentials, return null return null; } /** * Determines if integration tests should be skipped * * @returns true if integration tests should be skipped */ export function shouldSkipIntegrationTest(): boolean { if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.AZURE_DEVOPS_PAT) { console.log( 'Skipping integration test: No real Azure DevOps connection available', ); return true; } return false; } ``` -------------------------------------------------------------------------------- /docs/testing/setup.md: -------------------------------------------------------------------------------- ```markdown # Testing Setup Guide ## Environment Variables Tests that interact with Azure DevOps APIs (integration and e2e tests) require environment variables to run properly. These variables are automatically loaded from your `.env` file during test execution. Required variables: ``` AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_PAT=your-personal-access-token AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name ``` ## Test Structure Tests in this project are co-located with the code they're testing: ``` src/ features/ feature-name/ feature.ts feature.spec.unit.ts # Unit tests feature.spec.int.ts # Integration tests ``` E2E tests are only located at the server level: ``` src/ server.ts server.spec.e2e.ts # E2E tests ``` ## Import Pattern We use path aliases to make imports cleaner and easier to maintain. Instead of relative imports like: ```typescript import { someFunction } from '../../../../shared/utils'; ``` You can use the `@/` path alias: ```typescript import { someFunction } from '@/shared/utils'; ``` ### Test Helpers Test helpers are located in a centralized location for all tests: ```typescript import { getTestConnection, shouldSkipIntegrationTest } from '@/shared/test/test-helpers'; ``` ## Running Tests - Unit tests: `npm run test:unit` - Integration tests: `npm run test:int` - E2E tests: `npm run test:e2e` - All tests: `npm test` ## VSCode Integration The project includes VSCode settings that: 1. Show proper test icons for `*.spec.*.ts` files 2. Enable file nesting to group test files with their implementation 3. Configure TypeScript to prefer path aliases over relative imports These settings are stored in `.vscode/settings.json`. ``` -------------------------------------------------------------------------------- /src/features/work-items/update-work-item/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { updateWorkItem } from './feature'; import { AzureDevOpsError } from '../../../shared/errors'; // Unit tests should only focus on isolated logic // No real connections, HTTP requests, or dependencies describe('updateWorkItem unit', () => { test('should throw error when no fields are provided for update', async () => { // Arrange - mock connection, never used due to validation error const mockConnection: any = { getWorkItemTrackingApi: jest.fn(), }; // Act & Assert - empty options object should throw await expect( updateWorkItem( mockConnection, 123, {}, // No fields to update ), ).rejects.toThrow('At least one field must be provided for update'); }); test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: any = { getWorkItemTrackingApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect( updateWorkItem(mockConnection, 123, { title: 'Updated Title' }), ).rejects.toThrow(AzureDevOpsError); await expect( updateWorkItem(mockConnection, 123, { title: 'Updated Title' }), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: any = { getWorkItemTrackingApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect( updateWorkItem(mockConnection, 123, { title: 'Updated Title' }), ).rejects.toThrow('Failed to update work item: Unexpected error'); }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { createWiki } from './feature'; import { WikiType } from './schema'; import { getTestConnection } from '@/shared/test/test-helpers'; import axios from 'axios'; axios.interceptors.request.use((request) => { console.log('Starting Request', JSON.stringify(request, null, 2)); return request; }); describe('createWiki (Integration)', () => { let connection: WebApi | null = null; let projectName: string; const testWikiName = `TestWiki_${new Date().getTime()}`; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test.skip('should create a project wiki', async () => { // PERMANENTLY SKIPPED: Azure DevOps only allows one wiki per project. // Running this test multiple times would fail after the first wiki is created. // This test is kept for reference but cannot be run repeatedly. // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Create the wiki const wiki = await createWiki(connection, { name: testWikiName, projectId: projectName, type: WikiType.ProjectWiki, }); // Verify the wiki was created expect(wiki).toBeDefined(); expect(wiki.name).toBe(testWikiName); expect(wiki.projectId).toBe(projectName); expect(wiki.type).toBe(WikiType.ProjectWiki); }); // NOTE: We're not testing code wiki creation since that requires a repository // that would need to be created/cleaned up and is outside the scope of this test }); ``` -------------------------------------------------------------------------------- /src/features/wikis/list-wiki-pages/feature.ts: -------------------------------------------------------------------------------- ```typescript import * as azureDevOpsClient from '../../../clients/azure-devops'; import { AzureDevOpsError } from '../../../shared/errors/azure-devops-errors'; import { defaultOrg, defaultProject } from '../../../utils/environment'; import { ListWikiPagesOptions } from './schema'; /** * Summary information for a wiki page */ export interface WikiPageSummary { id: number; path: string; url?: string; order?: number; } /** * List wiki pages from a wiki * * @param options Options for listing wiki pages * @returns Array of wiki page summaries * @throws {AzureDevOpsResourceNotFoundError} When the wiki is not found * @throws {AzureDevOpsPermissionError} When the user does not have permission to access the wiki * @throws {AzureDevOpsError} When an error occurs while fetching the wiki pages */ export async function listWikiPages( options: ListWikiPagesOptions, ): Promise<WikiPageSummary[]> { const { organizationId, projectId, wikiId } = options; // Use defaults if not provided const orgId = organizationId || defaultOrg; const projId = projectId || defaultProject; try { // Create the client const client = await azureDevOpsClient.getWikiClient({ organizationId: orgId, }); // Get the wiki pages const pages = await client.listWikiPages(projId, wikiId); // Return the pages directly since the client interface now matches our requirements return pages.map((page) => ({ id: page.id, path: page.path, url: page.url, order: page.order, })); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Otherwise wrap it in an AzureDevOpsError throw new AzureDevOpsError('Failed to list wiki pages', { cause: error }); } } ``` -------------------------------------------------------------------------------- /src/features/wikis/update-wiki-page/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { updateWikiPage } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('updateWikiPage integration', () => { let connection: WebApi | null = null; let projectName: string; let organizationName: string; let wikiId: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; organizationName = process.env.AZURE_DEVOPS_ORGANIZATION || ''; // Note: You'll need to set this to a valid wiki ID in your environment wikiId = `${projectName}.wiki`; }); test('should update a wiki page in Azure DevOps', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Skip if no wiki ID is provided if (!wikiId) { console.log('Skipping test: No wiki ID provided'); return; } const testPagePath = '/test-page'; const testContent = '# Test Content\nThis is a test update.'; const testComment = 'Test update from integration test'; // Update the wiki page const result = await updateWikiPage({ organizationId: organizationName, projectId: projectName, wikiId: wikiId, pagePath: testPagePath, content: testContent, comment: testComment, }); // Verify the result expect(result).toBeDefined(); expect(result.path).toBe(testPagePath); expect(result.content).toBe(testContent); }); }); ``` -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- ```typescript /** * Jest setup file that runs before all tests */ import dotenv from 'dotenv'; import path from 'path'; // Load environment variables from .env file // Use silent mode to prevent warning when .env file is not found const result = dotenv.config({ path: path.resolve(process.cwd(), '.env'), }); // Only log if .env file was successfully loaded and DEBUG=true if (!result.error && process.env.DEBUG === 'true') { console.log('Environment variables loaded from .env file'); } // Increase timeout for integration tests jest.setTimeout(30000); // 30 seconds // Suppress console output during tests unless specifically desired const originalConsoleLog = console.log; const originalConsoleWarn = console.warn; const originalConsoleError = console.error; if (process.env.DEBUG !== 'true') { global.console.log = (...args: any[]) => { if ( args[0]?.toString().includes('Skip') || args[0]?.toString().includes('Environment') ) { originalConsoleLog(...args); } }; global.console.warn = (...args: any[]) => { if (args[0]?.toString().includes('Warning')) { originalConsoleWarn(...args); } }; global.console.error = (...args: any[]) => { originalConsoleError(...args); }; } // Global setup before tests run beforeAll(() => { console.log('Starting tests with Testing Trophy approach...'); }); // Global cleanup after all tests afterAll(() => { console.log('All tests completed.'); }); // Clear all mocks before each test beforeEach(() => { jest.clearAllMocks(); jest.spyOn(console, 'log').mockImplementation(originalConsoleLog); jest.spyOn(console, 'warn').mockImplementation(originalConsoleWarn); jest.spyOn(console, 'error').mockImplementation(originalConsoleError); }); // Restore all mocks after each test afterEach(() => { jest.restoreAllMocks(); }); ``` -------------------------------------------------------------------------------- /src/features/work-items/create-work-item/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { createWorkItem } from './feature'; import { AzureDevOpsError } from '../../../shared/errors'; // Unit tests should only focus on isolated logic // No real connections, HTTP requests, or dependencies describe('createWorkItem unit', () => { // Test for required title validation test('should throw error when title is not provided', async () => { // Arrange - mock connection, never used due to validation error const mockConnection: any = { getWorkItemTrackingApi: jest.fn(), }; // Act & Assert await expect( createWorkItem( mockConnection, 'TestProject', 'Task', { title: '' }, // Empty title ), ).rejects.toThrow('Title is required'); }); // Test for error propagation test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: any = { getWorkItemTrackingApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect( createWorkItem(mockConnection, 'TestProject', 'Task', { title: 'Test Task', }), ).rejects.toThrow(AzureDevOpsError); await expect( createWorkItem(mockConnection, 'TestProject', 'Task', { title: 'Test Task', }), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: any = { getWorkItemTrackingApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect( createWorkItem(mockConnection, 'TestProject', 'Task', { title: 'Test Task', }), ).rejects.toThrow('Failed to create work item: Unexpected error'); }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wikis/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { WikiV2 } from 'azure-devops-node-api/interfaces/WikiInterfaces'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; /** * Options for getting wikis */ export interface GetWikisOptions { /** * The ID or name of the organization * If not provided, the default organization will be used */ organizationId?: string; /** * The ID or name of the project * If not provided, the wikis from all projects will be returned */ projectId?: string; } /** * Get wikis in a project or organization * * @param connection The Azure DevOps WebApi connection * @param options Options for getting wikis * @returns List of wikis */ export async function getWikis( connection: WebApi, options: GetWikisOptions, ): Promise<WikiV2[]> { try { // Get the Wiki API client const wikiApi = await connection.getWikiApi(); // If a projectId is provided, get wikis for that specific project // Otherwise, get wikis for the entire organization const { projectId } = options; const wikis = await wikiApi.getAllWikis(projectId); return wikis || []; } catch (error) { // Handle resource not found errors specifically if ( error instanceof Error && error.message && error.message.includes('The resource cannot be found') ) { throw new AzureDevOpsResourceNotFoundError( `Resource not found: ${options.projectId ? `Project '${options.projectId}'` : 'Organization'}`, ); } // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Otherwise, wrap it in a generic error throw new AzureDevOpsError( `Failed to get wikis: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/pipelines/list-pipelines/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError, AzureDevOpsAuthenticationError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; import { ListPipelinesOptions, Pipeline } from '../types'; /** * List pipelines in a project * * @param connection The Azure DevOps WebApi connection * @param options Options for listing pipelines * @returns List of pipelines */ export async function listPipelines( connection: WebApi, options: ListPipelinesOptions, ): Promise<Pipeline[]> { try { const pipelinesApi = await connection.getPipelinesApi(); const { projectId, orderBy, top, continuationToken } = options; // Call the pipelines API to get the list of pipelines const pipelines = await pipelinesApi.listPipelines( projectId, orderBy, top, continuationToken, ); return pipelines; } catch (error) { // Handle specific error types if (error instanceof AzureDevOpsError) { throw error; } // Check for specific error types and convert to appropriate Azure DevOps errors if (error instanceof Error) { if ( error.message.includes('Authentication') || error.message.includes('Unauthorized') || error.message.includes('401') ) { throw new AzureDevOpsAuthenticationError( `Failed to authenticate: ${error.message}`, ); } if ( error.message.includes('not found') || error.message.includes('does not exist') || error.message.includes('404') ) { throw new AzureDevOpsResourceNotFoundError( `Project or resource not found: ${error.message}`, ); } } // Otherwise, wrap it in a generic error throw new AzureDevOpsError( `Failed to list pipelines: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { getRepository } from './feature'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; // Unit tests should only focus on isolated logic // No real connections, HTTP requests, or dependencies describe('getRepository unit', () => { test('should propagate resource not found errors', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(null), // Simulate repository not found })), }; // Act & Assert await expect( getRepository(mockConnection, 'test-project', 'non-existent-repo'), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); await expect( getRepository(mockConnection, 'test-project', 'non-existent-repo'), ).rejects.toThrow( "Repository 'non-existent-repo' not found in project 'test-project'", ); }); test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect( getRepository(mockConnection, 'test-project', 'test-repo'), ).rejects.toThrow(AzureDevOpsError); await expect( getRepository(mockConnection, 'test-project', 'test-repo'), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect( getRepository(mockConnection, 'test-project', 'test-repo'), ).rejects.toThrow('Failed to get repository: Unexpected error'); }); }); ``` -------------------------------------------------------------------------------- /docs/ci-setup.md: -------------------------------------------------------------------------------- ```markdown # CI Environment Setup for Integration Tests This document explains how to set up the CI environment to run integration tests with Azure DevOps. ## GitHub Secrets Configuration To run integration tests in the CI environment, you need to configure the following GitHub Secrets: 1. **AZURE_DEVOPS_ORG_URL**: The URL of your Azure DevOps organization (e.g., `https://dev.azure.com/your-organization`) 2. **AZURE_DEVOPS_PAT**: A Personal Access Token with appropriate permissions 3. **AZURE_DEVOPS_DEFAULT_PROJECT** (optional): The default project to use for tests ### Setting up GitHub Secrets 1. Go to your GitHub repository 2. Click on "Settings" > "Secrets and variables" > "Actions" 3. Click on "New repository secret" 4. Add each of the required secrets: #### AZURE_DEVOPS_ORG_URL - Name: `AZURE_DEVOPS_ORG_URL` - Value: `https://dev.azure.com/your-organization` #### AZURE_DEVOPS_PAT - Name: `AZURE_DEVOPS_PAT` - Value: Your Personal Access Token #### AZURE_DEVOPS_DEFAULT_PROJECT (optional) - Name: `AZURE_DEVOPS_DEFAULT_PROJECT` - Value: Your project name ## Personal Access Token (PAT) Requirements The PAT used for integration tests should have the following permissions: - **Code**: Read & Write - **Work Items**: Read & Write - **Build**: Read & Execute - **Project and Team**: Read - **Graph**: Read - **Release**: Read & Execute ## Security Considerations - Use a dedicated Azure DevOps organization or project for testing - Create a PAT with the minimum required permissions - Consider setting an expiration date for the PAT - Regularly rotate the PAT used in GitHub Secrets ## Troubleshooting If integration tests fail in CI: 1. Check the GitHub Actions logs for detailed error messages 2. Verify that the PAT has not expired 3. Ensure the PAT has the required permissions 4. Confirm that the organization URL is correct 5. Check if the default project exists and is accessible with the provided PAT ``` -------------------------------------------------------------------------------- /src/features/pull-requests/create-pull-request/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError } from '../../../shared/errors'; import { CreatePullRequestOptions, PullRequest } from '../types'; /** * Create a pull request * * @param connection The Azure DevOps WebApi connection * @param projectId The ID or name of the project * @param repositoryId The ID or name of the repository * @param options Options for creating the pull request * @returns The created pull request */ export async function createPullRequest( connection: WebApi, projectId: string, repositoryId: string, options: CreatePullRequestOptions, ): Promise<PullRequest> { try { if (!options.title) { throw new Error('Title is required'); } if (!options.sourceRefName) { throw new Error('Source branch is required'); } if (!options.targetRefName) { throw new Error('Target branch is required'); } const gitApi = await connection.getGitApi(); // Create the pull request object const pullRequest: PullRequest = { title: options.title, description: options.description, sourceRefName: options.sourceRefName, targetRefName: options.targetRefName, isDraft: options.isDraft || false, workItemRefs: options.workItemRefs?.map((id) => ({ id: id.toString(), })), reviewers: options.reviewers?.map((reviewer) => ({ id: reviewer, isRequired: true, })), ...options.additionalProperties, }; // Create the pull request const createdPullRequest = await gitApi.createPullRequest( pullRequest, repositoryId, projectId, ); if (!createdPullRequest) { throw new Error('Failed to create pull request'); } return createdPullRequest; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/search/index.ts: -------------------------------------------------------------------------------- ```typescript export * from './schemas'; export * from './types'; export * from './search-code'; export * from './search-wiki'; export * from './search-work-items'; // Export tool definitions export * from './tool-definitions'; // New exports for request handling import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; import { RequestIdentifier, RequestHandler, } from '../../shared/types/request-handler'; import { SearchCodeSchema, SearchWikiSchema, SearchWorkItemsSchema, searchCode, searchWiki, searchWorkItems, } from './'; /** * Checks if the request is for the search feature */ export const isSearchRequest: RequestIdentifier = ( request: CallToolRequest, ): boolean => { const toolName = request.params.name; return ['search_code', 'search_wiki', 'search_work_items'].includes(toolName); }; /** * Handles search feature requests */ export const handleSearchRequest: RequestHandler = async ( connection: WebApi, request: CallToolRequest, ): Promise<{ content: Array<{ type: string; text: string }> }> => { switch (request.params.name) { case 'search_code': { const args = SearchCodeSchema.parse(request.params.arguments); const result = await searchCode(connection, args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'search_wiki': { const args = SearchWikiSchema.parse(request.params.arguments); const result = await searchWiki(connection, args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'search_work_items': { const args = SearchWorkItemsSchema.parse(request.params.arguments); const result = await searchWorkItems(connection, args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } default: throw new Error(`Unknown search tool: ${request.params.name}`); } }; ``` -------------------------------------------------------------------------------- /src/features/projects/get-project/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getProject } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getProject integration', () => { let connection: WebApi | null = null; let projectName: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test('should retrieve a real project from Azure DevOps', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Act - make an actual API call to Azure DevOps const result = await getProject(connection, projectName); // Assert on the actual response expect(result).toBeDefined(); expect(result.name).toBe(projectName); expect(result.id).toBeDefined(); expect(result.url).toBeDefined(); expect(result.state).toBeDefined(); // Verify basic project structure expect(result.visibility).toBeDefined(); expect(result.lastUpdateTime).toBeDefined(); }); test('should throw error when project is not found', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Use a non-existent project name const nonExistentProjectName = 'non-existent-project-' + Date.now(); // Act & Assert - should throw an error for non-existent project await expect( getProject(connection, nonExistentProjectName), ).rejects.toThrow(/not found|Failed to get project/); }); }); ``` -------------------------------------------------------------------------------- /src/features/organizations/index.ts: -------------------------------------------------------------------------------- ```typescript // Re-export schemas and types export * from './schemas'; export * from './types'; // Re-export features export * from './list-organizations'; // Export tool definitions export * from './tool-definitions'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; import { RequestIdentifier, RequestHandler, } from '../../shared/types/request-handler'; import { listOrganizations } from './list-organizations'; import { AzureDevOpsConfig } from '../../shared/types'; import { AuthenticationMethod } from '../../shared/auth'; /** * Checks if the request is for the organizations feature */ export const isOrganizationsRequest: RequestIdentifier = ( request: CallToolRequest, ): boolean => { const toolName = request.params.name; return ['list_organizations'].includes(toolName); }; /** * Handles organizations feature requests */ export const handleOrganizationsRequest: RequestHandler = async ( connection: WebApi, request: CallToolRequest, ): Promise<{ content: Array<{ type: string; text: string }> }> => { switch (request.params.name) { case 'list_organizations': { // Use environment variables for authentication method and PAT // This matches how other features handle authentication const config: AzureDevOpsConfig = { authMethod: process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' ? AuthenticationMethod.PersonalAccessToken : process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' ? AuthenticationMethod.AzureCli : AuthenticationMethod.AzureIdentity, personalAccessToken: process.env.AZURE_DEVOPS_PAT, organizationUrl: connection.serverUrl || '', }; const result = await listOrganizations(config); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } default: throw new Error(`Unknown organizations tool: ${request.params.name}`); } }; ``` -------------------------------------------------------------------------------- /src/features/pipelines/get-pipeline/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError, AzureDevOpsAuthenticationError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; import { GetPipelineOptions, Pipeline } from '../types'; /** * Get a specific pipeline by ID * * @param connection The Azure DevOps WebApi connection * @param options Options for getting a pipeline * @returns Pipeline details */ export async function getPipeline( connection: WebApi, options: GetPipelineOptions, ): Promise<Pipeline> { try { const pipelinesApi = await connection.getPipelinesApi(); const { projectId, pipelineId, pipelineVersion } = options; // Call the pipelines API to get the pipeline const pipeline = await pipelinesApi.getPipeline( projectId, pipelineId, pipelineVersion, ); // If pipeline not found, API returns null instead of throwing error if (pipeline === null) { throw new AzureDevOpsResourceNotFoundError( `Pipeline not found with ID: ${pipelineId}`, ); } return pipeline; } catch (error) { // Handle specific error types if (error instanceof AzureDevOpsError) { throw error; } // Check for specific error types and convert to appropriate Azure DevOps errors if (error instanceof Error) { if ( error.message.includes('Authentication') || error.message.includes('Unauthorized') || error.message.includes('401') ) { throw new AzureDevOpsAuthenticationError( `Failed to authenticate: ${error.message}`, ); } if ( error.message.includes('not found') || error.message.includes('does not exist') || error.message.includes('404') ) { throw new AzureDevOpsResourceNotFoundError( `Pipeline or project not found: ${error.message}`, ); } } // Otherwise, wrap it in a generic error throw new AzureDevOpsError( `Failed to get pipeline: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /docs/tools/organizations.md: -------------------------------------------------------------------------------- ```markdown # Azure DevOps Organizations Tools This document describes the tools available for working with Azure DevOps organizations. ## list_organizations Lists all Azure DevOps organizations accessible to the authenticated user. ### Description The `list_organizations` tool retrieves all Azure DevOps organizations that the authenticated user has access to. This is useful for discovering which organizations are available before performing operations on specific projects or repositories. Unlike most other tools in this server, this tool uses Axios for direct API calls rather than the Azure DevOps Node API client, as the WebApi client doesn't support the organizations endpoint. ### Parameters This tool doesn't require any parameters. ```json { // No parameters required } ``` ### Response The tool returns an array of organization objects, each containing: - `id`: The unique identifier of the organization - `name`: The name of the organization - `url`: The URL of the organization Example response: ```json [ { "id": "org1-id", "name": "org1-name", "url": "https://dev.azure.com/org1-name" }, { "id": "org2-id", "name": "org2-name", "url": "https://dev.azure.com/org2-name" } ] ``` ### Error Handling The tool may throw the following errors: - `AzureDevOpsAuthenticationError`: If authentication fails or the user profile cannot be retrieved - General errors: If the accounts API call fails or other unexpected errors occur ### Example Usage ```typescript // Example MCP client call const result = await mcpClient.callTool('list_organizations', {}); console.log(result); ``` ### Implementation Details This tool uses a two-step process to retrieve organizations: 1. First, it gets the user profile from `https://app.vssps.visualstudio.com/_apis/profile/profiles/me` 2. Then it extracts the `publicAlias` from the profile response 3. Finally, it uses the `publicAlias` to get organizations from `https://app.vssps.visualstudio.com/_apis/accounts?memberId={publicAlias}` Authentication is handled using Basic Auth with the Personal Access Token. ``` -------------------------------------------------------------------------------- /src/features/projects/schemas.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../utils/environment'; /** * Schema for getting a project */ export const GetProjectSchema = z.object({ projectId: z .string() .optional() .describe(`The ID or name of the project (Default: ${defaultProject})`), organizationId: z .string() .optional() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), }); /** * Schema for getting detailed project information */ export const GetProjectDetailsSchema = z.object({ projectId: z .string() .optional() .describe(`The ID or name of the project (Default: ${defaultProject})`), organizationId: z .string() .optional() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), includeProcess: z .boolean() .optional() .default(false) .describe('Include process information in the project result'), includeWorkItemTypes: z .boolean() .optional() .default(false) .describe('Include work item types and their structure'), includeFields: z .boolean() .optional() .default(false) .describe('Include field information for work item types'), includeTeams: z .boolean() .optional() .default(false) .describe('Include associated teams in the project result'), expandTeamIdentity: z .boolean() .optional() .default(false) .describe('Expand identity information in the team objects'), }); /** * Schema for listing projects */ export const ListProjectsSchema = z.object({ organizationId: z .string() .optional() .describe(`The ID or name of the organization (Default: ${defaultOrg})`), stateFilter: z .number() .optional() .describe( 'Filter on team project state (0: all, 1: well-formed, 2: creating, 3: deleting, 4: new)', ), top: z.number().optional().describe('Maximum number of projects to return'), skip: z.number().optional().describe('Number of projects to skip'), continuationToken: z .number() .optional() .describe('Gets the projects after the continuation token provided'), }); ``` -------------------------------------------------------------------------------- /src/features/repositories/list-repositories/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { listRepositories } from './feature'; import { AzureDevOpsError } from '../../../shared/errors'; // Unit tests should only focus on isolated logic describe('listRepositories unit', () => { test('should return empty array when no repositories are found', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepositories: jest.fn().mockResolvedValue([]), // No repositories found })), }; // Act const result = await listRepositories(mockConnection, { projectId: 'test-project', }); // Assert expect(result).toEqual([]); }); test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect( listRepositories(mockConnection, { projectId: 'test-project' }), ).rejects.toThrow(AzureDevOpsError); await expect( listRepositories(mockConnection, { projectId: 'test-project' }), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect( listRepositories(mockConnection, { projectId: 'test-project' }), ).rejects.toThrow('Failed to list repositories: Unexpected error'); }); test('should respect the includeLinks option', async () => { // Arrange const mockGetRepositories = jest.fn().mockResolvedValue([]); const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepositories: mockGetRepositories, })), }; // Act await listRepositories(mockConnection, { projectId: 'test-project', includeLinks: true, }); // Assert expect(mockGetRepositories).toHaveBeenCalledWith('test-project', true); }); }); ``` -------------------------------------------------------------------------------- /src/utils/environment.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript // Mock the environment module before importing jest.mock('./environment', () => { const original = jest.requireActual('./environment'); return { ...original, // We'll keep getOrgNameFromUrl as is for its own tests getOrgNameFromUrl: original.getOrgNameFromUrl, }; }); import { getOrgNameFromUrl } from './environment'; describe('environment utilities', () => { // Store original environment variables const originalEnv = { ...process.env }; // Reset environment variables after each test afterEach(() => { process.env = { ...originalEnv }; jest.resetModules(); }); describe('getOrgNameFromUrl', () => { it('should extract organization name from Azure DevOps URL', () => { const url = 'https://dev.azure.com/test-organization'; expect(getOrgNameFromUrl(url)).toBe('test-organization'); }); it('should handle URLs with paths after the organization name', () => { const url = 'https://dev.azure.com/test-organization/project'; expect(getOrgNameFromUrl(url)).toBe('test-organization'); }); it('should return "unknown-organization" when URL is undefined', () => { expect(getOrgNameFromUrl(undefined)).toBe('unknown-organization'); }); it('should return "unknown-organization" when URL is empty', () => { expect(getOrgNameFromUrl('')).toBe('unknown-organization'); }); it('should return "unknown-organization" when URL does not match pattern', () => { const url = 'https://example.com/test-organization'; expect(getOrgNameFromUrl(url)).toBe('unknown-organization'); }); }); describe('defaultProject and defaultOrg', () => { // Since we can't easily test the environment variable initialization directly, // we'll test the getOrgNameFromUrl function which is used to derive defaultOrg it('should handle the real default case', () => { // This test is more of a documentation than a real test const orgNameFromUrl = getOrgNameFromUrl( process.env.AZURE_DEVOPS_ORG_URL, ); // We can't assert an exact value since it depends on the environment expect(typeof orgNameFromUrl).toBe('string'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/get-wiki-page/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getWikiPage } from './feature'; import { getWikis } from '../get-wikis/feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; import { getOrgNameFromUrl } from '@/utils/environment'; process.env.AZURE_DEVOPS_DEFAULT_PROJECT = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'default-project'; describe('getWikiPage integration', () => { let connection: WebApi | null = null; let projectName: string; let orgUrl: string; beforeAll(async () => { // Mock the required environment variable for testing process.env.AZURE_DEVOPS_ORG_URL = process.env.AZURE_DEVOPS_ORG_URL || 'https://example.visualstudio.com'; // Get a real connection using environment variables connection = await getTestConnection(); // Get and validate required environment variables const envProjectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; if (!envProjectName) { throw new Error( 'AZURE_DEVOPS_DEFAULT_PROJECT environment variable is required', ); } projectName = envProjectName; const envOrgUrl = process.env.AZURE_DEVOPS_ORG_URL; if (!envOrgUrl) { throw new Error('AZURE_DEVOPS_ORG_URL environment variable is required'); } orgUrl = envOrgUrl; }); test('should retrieve a wiki page', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // First get available wikis const wikis = await getWikis(connection, { projectId: projectName }); // Skip if no wikis are available if (wikis.length === 0) { console.log('Skipping test: No wikis available in the project'); return; } // Use the first available wiki const wiki = wikis[0]; if (!wiki.name) { throw new Error('Wiki name is undefined'); } // Get the wiki page const result = await getWikiPage({ organizationId: getOrgNameFromUrl(orgUrl), projectId: projectName, wikiId: wiki.name, pagePath: '/test', }); // Verify the result expect(result).toBeDefined(); expect(typeof result).toBe('string'); }); }); ``` -------------------------------------------------------------------------------- /src/features/projects/get-project/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { getProject } from './feature'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; import { WebApi } from 'azure-devops-node-api'; // Create a partial mock interface for ICoreApi interface MockCoreApi { getProject: jest.Mock<Promise<TeamProject | null>>; } // Create a mock connection that resembles WebApi with minimal implementation interface MockConnection { getCoreApi: jest.Mock<Promise<MockCoreApi>>; serverUrl?: string; authHandler?: unknown; rest?: unknown; vsoClient?: unknown; } // Unit tests should only focus on isolated logic describe('getProject unit', () => { test('should throw resource not found error when project is null', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(null), // Simulate project not found }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), }; // Act & Assert await expect( getProject(mockConnection as unknown as WebApi, 'non-existent-project'), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); await expect( getProject(mockConnection as unknown as WebApi, 'non-existent-project'), ).rejects.toThrow("Project 'non-existent-project' not found"); }); test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: MockConnection = { getCoreApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect( getProject(mockConnection as unknown as WebApi, 'test-project'), ).rejects.toThrow(AzureDevOpsError); await expect( getProject(mockConnection as unknown as WebApi, 'test-project'), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: MockConnection = { getCoreApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect( getProject(mockConnection as unknown as WebApi, 'test-project'), ).rejects.toThrow('Failed to get project: Unexpected error'); }); }); ``` -------------------------------------------------------------------------------- /finish_task.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # Check if a PR title is provided if [ -z "$1" ]; then echo "Usage: $0 <pr_title> [pr_description]" echo "Example: $0 \"Add user authentication\" \"This PR implements user login and registration\"" exit 1 fi PR_TITLE="$1" PR_DESCRIPTION="${2:-"No description provided."}" # Get current branch name CURRENT_BRANCH=$(git symbolic-ref --short HEAD) if [ "$CURRENT_BRANCH" = "main" ]; then echo "Error: You are on the main branch. Please switch to a feature branch." exit 1 fi # Check if there are any uncommitted changes if ! git diff --quiet || ! git diff --staged --quiet; then # Stage all changes echo "Staging all changes..." git add . # Commit changes echo "Committing changes with title: $PR_TITLE" git commit -m "$PR_TITLE" -m "$PR_DESCRIPTION" if [ $? -ne 0 ]; then echo "Failed to commit changes." exit 1 fi # Push changes to remote echo "Pushing changes to origin/$CURRENT_BRANCH..." git push -u origin "$CURRENT_BRANCH" if [ $? -ne 0 ]; then echo "Failed to push changes to remote." exit 1 fi else echo "No uncommitted changes found. Proceeding with PR creation for already committed changes." fi # Create PR using GitHub CLI echo "Creating pull request..." if command -v gh &> /dev/null; then PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_DESCRIPTION" --base main --head "$CURRENT_BRANCH") if [ $? -eq 0 ]; then echo "Pull request created successfully!" echo "PR URL: $PR_URL" # Try to open the PR URL in the default browser if command -v xdg-open &> /dev/null; then xdg-open "$PR_URL" &> /dev/null & # Linux elif command -v open &> /dev/null; then open "$PR_URL" &> /dev/null & # macOS elif command -v start &> /dev/null; then start "$PR_URL" &> /dev/null & # Windows else echo "Could not automatically open the PR in your browser." fi else echo "Failed to create pull request using GitHub CLI." echo "Please create a PR manually at: https://github.com/$(git config --get remote.origin.url | sed 's/.*github.com[:\/]\(.*\)\.git/\1/')/pull/new/$CURRENT_BRANCH" fi else echo "GitHub CLI (gh) not found. Please install it to create PRs from the command line." echo "You can create a PR manually at: https://github.com/$(git config --get remote.origin.url | sed 's/.*github.com[:\/]\(.*\)\.git/\1/')/pull/new/$CURRENT_BRANCH" fi echo "Task completion workflow finished!" ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getRepository } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getRepository integration', () => { let connection: WebApi | null = null; let projectName: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test('should retrieve a real repository from Azure DevOps', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // First, get a list of repos to find one to test with const gitApi = await connection.getGitApi(); const repos = await gitApi.getRepositories(projectName); // Skip if no repos are available if (!repos || repos.length === 0) { console.log('Skipping test: No repositories available in the project'); return; } // Use the first repo as a test subject const testRepo = repos[0]; // Act - make an actual API call to Azure DevOps const result = await getRepository( connection, projectName, testRepo.name || testRepo.id || '', ); // Assert on the actual response expect(result).toBeDefined(); expect(result.id).toBe(testRepo.id); expect(result.name).toBe(testRepo.name); expect(result.project).toBeDefined(); if (result.project) { expect(result.project.name).toBe(projectName); } }); test('should throw error when repository is not found', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Use a non-existent repository name const nonExistentRepoName = 'non-existent-repo-' + Date.now(); // Act & Assert - should throw an error for non-existent repo await expect( getRepository(connection, projectName, nonExistentRepoName), ).rejects.toThrow(/not found|Failed to get repository/); }); }); ``` -------------------------------------------------------------------------------- /src/features/pipelines/index.ts: -------------------------------------------------------------------------------- ```typescript // Re-export types export * from './types'; // Re-export features export * from './list-pipelines'; export * from './get-pipeline'; export * from './trigger-pipeline'; // Export tool definitions export * from './tool-definitions'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; import { RequestIdentifier, RequestHandler, } from '../../shared/types/request-handler'; import { ListPipelinesSchema } from './list-pipelines'; import { GetPipelineSchema } from './get-pipeline'; import { TriggerPipelineSchema } from './trigger-pipeline'; import { listPipelines } from './list-pipelines'; import { getPipeline } from './get-pipeline'; import { triggerPipeline } from './trigger-pipeline'; import { defaultProject } from '../../utils/environment'; /** * Checks if the request is for the pipelines feature */ export const isPipelinesRequest: RequestIdentifier = ( request: CallToolRequest, ): boolean => { const toolName = request.params.name; return ['list_pipelines', 'get_pipeline', 'trigger_pipeline'].includes( toolName, ); }; /** * Handles pipelines feature requests */ export const handlePipelinesRequest: RequestHandler = async ( connection: WebApi, request: CallToolRequest, ): Promise<{ content: Array<{ type: string; text: string }> }> => { switch (request.params.name) { case 'list_pipelines': { const args = ListPipelinesSchema.parse(request.params.arguments); const result = await listPipelines(connection, { ...args, projectId: args.projectId ?? defaultProject, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'get_pipeline': { const args = GetPipelineSchema.parse(request.params.arguments); const result = await getPipeline(connection, { ...args, projectId: args.projectId ?? defaultProject, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'trigger_pipeline': { const args = TriggerPipelineSchema.parse(request.params.arguments); const result = await triggerPipeline(connection, { ...args, projectId: args.projectId ?? defaultProject, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } default: throw new Error(`Unknown pipelines tool: ${request.params.name}`); } }; ``` -------------------------------------------------------------------------------- /src/features/repositories/list-repositories/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { listRepositories } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; import { ListRepositoriesOptions } from '../types'; describe('listRepositories integration', () => { let connection: WebApi | null = null; let projectName: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test('should list repositories in a project', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } const options: ListRepositoriesOptions = { projectId: projectName, }; // Act - make an actual API call to Azure DevOps const result = await listRepositories(connection, options); // Assert on the actual response expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); // Check structure of returned items (even if empty) if (result.length > 0) { const firstRepo = result[0]; expect(firstRepo.id).toBeDefined(); expect(firstRepo.name).toBeDefined(); expect(firstRepo.project).toBeDefined(); if (firstRepo.project) { expect(firstRepo.project.name).toBe(projectName); } } }); test('should include links when option is specified', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } const options: ListRepositoriesOptions = { projectId: projectName, includeLinks: true, }; // Act - make an actual API call to Azure DevOps const result = await listRepositories(connection, options); // Assert on the actual response expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); // Verify links are included, if repositories exist if (result.length > 0) { const firstRepo = result[0]; expect(firstRepo._links).toBeDefined(); } }); }); ``` -------------------------------------------------------------------------------- /src/features/projects/list-projects/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { listProjects } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; import { ListProjectsOptions } from '../types'; describe('listProjects integration', () => { let connection: WebApi | null = null; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); }); test('should list projects in the organization', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Act - make an actual API call to Azure DevOps const result = await listProjects(connection); // Assert on the actual response expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); // Check structure of returned items (even if empty) if (result.length > 0) { const firstProject = result[0]; expect(firstProject.id).toBeDefined(); expect(firstProject.name).toBeDefined(); expect(firstProject.url).toBeDefined(); expect(firstProject.state).toBeDefined(); } }); test('should apply pagination options', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } const options: ListProjectsOptions = { top: 2, // Only get up to 2 projects }; // Act - make an actual API call to Azure DevOps const result = await listProjects(connection, options); // Assert on the actual response expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); expect(result.length).toBeLessThanOrEqual(2); // If we have projects, check for correct limit if (result.length > 0) { // Get all projects to compare const allProjects = await listProjects(connection); // If we have more than 2 total projects, pagination should have limited results if (allProjects.length > 2) { expect(result.length).toBe(2); expect(result.length).toBeLessThan(allProjects.length); } } }); }); ``` -------------------------------------------------------------------------------- /src/features/users/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { isUsersRequest, handleUsersRequest } from './index'; import { getMe } from './get-me'; // Mock the imported modules jest.mock('./get-me', () => ({ getMe: jest.fn(), })); describe('Users Request Handlers', () => { const mockConnection = {} as WebApi; describe('isUsersRequest', () => { it('should return true for users requests', () => { const request = { params: { name: 'get_me', arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isUsersRequest(request)).toBe(true); }); it('should return false for non-users requests', () => { const request = { params: { name: 'list_projects', arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isUsersRequest(request)).toBe(false); }); }); describe('handleUsersRequest', () => { it('should handle get_me request', async () => { const mockUserProfile = { id: 'user-id-123', displayName: 'Test User', email: '[email protected]', }; (getMe as jest.Mock).mockResolvedValue(mockUserProfile); const request = { params: { name: 'get_me', arguments: {}, }, method: 'tools/call', } as CallToolRequest; const response = await handleUsersRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockUserProfile, ); expect(getMe).toHaveBeenCalledWith(mockConnection); }); it('should throw error for unknown tool', async () => { const request = { params: { name: 'unknown_tool', arguments: {}, }, method: 'tools/call', } as CallToolRequest; await expect(handleUsersRequest(mockConnection, request)).rejects.toThrow( 'Unknown users tool', ); }); it('should propagate errors from user functions', async () => { const mockError = new Error('Test error'); (getMe as jest.Mock).mockRejectedValue(mockError); const request = { params: { name: 'get_me', arguments: {}, }, method: 'tools/call', } as CallToolRequest; await expect(handleUsersRequest(mockConnection, request)).rejects.toThrow( mockError, ); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { AzureDevOpsResourceNotFoundError, AzureDevOpsError, } from '../../../shared/errors'; import { GetRepositoryDetailsOptions, RepositoryDetails } from '../types'; /** * Get detailed information about a repository * * @param connection The Azure DevOps WebApi connection * @param options Options for getting repository details * @returns The repository details including optional statistics and refs * @throws {AzureDevOpsResourceNotFoundError} If the repository is not found */ export async function getRepositoryDetails( connection: WebApi, options: GetRepositoryDetailsOptions, ): Promise<RepositoryDetails> { try { const gitApi = await connection.getGitApi(); // Get the basic repository information const repository = await gitApi.getRepository( options.repositoryId, options.projectId, ); if (!repository) { throw new AzureDevOpsResourceNotFoundError( `Repository '${options.repositoryId}' not found in project '${options.projectId}'`, ); } // Initialize the response object const response: RepositoryDetails = { repository, }; // Get branch statistics if requested if (options.includeStatistics) { let baseVersionDescriptor = undefined; // If a specific branch name is provided, create a version descriptor for it if (options.branchName) { baseVersionDescriptor = { version: options.branchName, versionType: GitVersionType.Branch, }; } const branchStats = await gitApi.getBranches( repository.id || '', options.projectId, baseVersionDescriptor, ); response.statistics = { branches: branchStats || [], }; } // Get repository refs if requested if (options.includeRefs) { const filter = options.refFilter || undefined; const refs = await gitApi.getRefs( repository.id || '', options.projectId, filter, ); if (refs) { response.refs = { value: refs, count: refs.length, }; } else { response.refs = { value: [], count: 0, }; } } return response; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to get repository details: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/shared/enums/index.ts: -------------------------------------------------------------------------------- ```typescript import { CommentThreadStatus, CommentType, GitVersionType, } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; /** * Generic enum mapper that creates bidirectional mappings between strings and numeric enums */ function createEnumMapper( mappings: Record<string, number>, defaultStringValue = 'unknown', ) { // Create reverse mapping from enum values to strings const reverseMap = Object.entries(mappings).reduce( (acc, [key, value]) => { acc[value] = key; return acc; }, {} as Record<number, string>, ); return { toEnum: (value: string): number | undefined => { const lowerValue = value.toLowerCase(); return mappings[lowerValue]; }, toString: (value: number): string => { return reverseMap[value] ?? defaultStringValue; }, }; } /** * CommentThreadStatus enum mappings */ export const commentThreadStatusMapper = createEnumMapper({ unknown: CommentThreadStatus.Unknown, active: CommentThreadStatus.Active, fixed: CommentThreadStatus.Fixed, wontfix: CommentThreadStatus.WontFix, closed: CommentThreadStatus.Closed, bydesign: CommentThreadStatus.ByDesign, pending: CommentThreadStatus.Pending, }); /** * CommentType enum mappings */ export const commentTypeMapper = createEnumMapper({ unknown: CommentType.Unknown, text: CommentType.Text, codechange: CommentType.CodeChange, system: CommentType.System, }); /** * PullRequestStatus enum mappings */ export const pullRequestStatusMapper = createEnumMapper({ active: PullRequestStatus.Active, abandoned: PullRequestStatus.Abandoned, completed: PullRequestStatus.Completed, }); /** * GitVersionType enum mappings */ export const gitVersionTypeMapper = createEnumMapper({ branch: GitVersionType.Branch, commit: GitVersionType.Commit, tag: GitVersionType.Tag, }); /** * Transform comment thread status from numeric to string */ export function transformCommentThreadStatus( status?: number, ): string | undefined { return status !== undefined ? commentThreadStatusMapper.toString(status) : undefined; } /** * Transform comment type from numeric to string */ export function transformCommentType(type?: number): string | undefined { return type !== undefined ? commentTypeMapper.toString(type) : undefined; } /** * Transform pull request status from numeric to string */ export function transformPullRequestStatus( status?: number, ): string | undefined { return status !== undefined ? pullRequestStatusMapper.toString(status) : undefined; } ``` -------------------------------------------------------------------------------- /src/features/projects/index.ts: -------------------------------------------------------------------------------- ```typescript // Re-export schemas and types export * from './schemas'; export * from './types'; // Re-export features export * from './get-project'; export * from './get-project-details'; export * from './list-projects'; // Export tool definitions export * from './tool-definitions'; // New exports for request handling import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; import { RequestIdentifier, RequestHandler, } from '../../shared/types/request-handler'; import { defaultProject } from '../../utils/environment'; import { GetProjectSchema, GetProjectDetailsSchema, ListProjectsSchema, getProject, getProjectDetails, listProjects, } from './'; /** * Checks if the request is for the projects feature */ export const isProjectsRequest: RequestIdentifier = ( request: CallToolRequest, ): boolean => { const toolName = request.params.name; return ['list_projects', 'get_project', 'get_project_details'].includes( toolName, ); }; /** * Handles projects feature requests */ export const handleProjectsRequest: RequestHandler = async ( connection: WebApi, request: CallToolRequest, ): Promise<{ content: Array<{ type: string; text: string }> }> => { switch (request.params.name) { case 'list_projects': { const args = ListProjectsSchema.parse(request.params.arguments); const result = await listProjects(connection, { stateFilter: args.stateFilter, top: args.top, skip: args.skip, continuationToken: args.continuationToken, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'get_project': { const args = GetProjectSchema.parse(request.params.arguments); const result = await getProject( connection, args.projectId ?? defaultProject, ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'get_project_details': { const args = GetProjectDetailsSchema.parse(request.params.arguments); const result = await getProjectDetails(connection, { projectId: args.projectId ?? defaultProject, includeProcess: args.includeProcess, includeWorkItemTypes: args.includeWorkItemTypes, includeFields: args.includeFields, includeTeams: args.includeTeams, expandTeamIdentity: args.expandTeamIdentity, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } default: throw new Error(`Unknown projects tool: ${request.params.name}`); } }; ``` -------------------------------------------------------------------------------- /src/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { normalizeAuthMethod } from './index'; import { AuthenticationMethod } from './shared/auth/auth-factory'; describe('index', () => { describe('normalizeAuthMethod', () => { it('should return AzureIdentity when authMethodStr is undefined', () => { // Arrange const authMethodStr = undefined; // Act const result = normalizeAuthMethod(authMethodStr); // Assert expect(result).toBe(AuthenticationMethod.AzureIdentity); }); it('should return AzureIdentity when authMethodStr is empty', () => { // Arrange const authMethodStr = ''; // Act const result = normalizeAuthMethod(authMethodStr); // Assert expect(result).toBe(AuthenticationMethod.AzureIdentity); }); it('should handle PersonalAccessToken case-insensitively', () => { // Arrange const variations = ['pat', 'PAT', 'Pat', 'pAt', 'paT']; // Act & Assert variations.forEach((variant) => { expect(normalizeAuthMethod(variant)).toBe( AuthenticationMethod.PersonalAccessToken, ); }); }); it('should handle AzureIdentity case-insensitively', () => { // Arrange const variations = [ 'azure-identity', 'AZURE-IDENTITY', 'Azure-Identity', 'azure-Identity', 'Azure-identity', ]; // Act & Assert variations.forEach((variant) => { expect(normalizeAuthMethod(variant)).toBe( AuthenticationMethod.AzureIdentity, ); }); }); it('should handle AzureCli case-insensitively', () => { // Arrange const variations = [ 'azure-cli', 'AZURE-CLI', 'Azure-Cli', 'azure-Cli', 'Azure-cli', ]; // Act & Assert variations.forEach((variant) => { expect(normalizeAuthMethod(variant)).toBe( AuthenticationMethod.AzureCli, ); }); }); it('should return AzureIdentity for unrecognized values', () => { // Arrange const unrecognized = [ 'unknown', 'azureCli', // no hyphen 'azureIdentity', // no hyphen 'personal-access-token', // not matching enum value 'cli', 'identity', ]; // Act & Assert (mute stderr for warning messages) const originalStderrWrite = process.stderr.write; process.stderr.write = jest.fn(); try { unrecognized.forEach((value) => { expect(normalizeAuthMethod(value)).toBe( AuthenticationMethod.AzureIdentity, ); }); } finally { process.stderr.write = originalStderrWrite; } }); }); }); ``` -------------------------------------------------------------------------------- /src/features/pipelines/get-pipeline/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPipeline } from './feature'; import { listPipelines } from '../list-pipelines/feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '../../../shared/test/test-helpers'; describe('getPipeline integration', () => { let connection: WebApi | null = null; let projectId: string; let existingPipelineId: number | null = null; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); // Get the project ID from environment variables, fallback to default projectId = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; // Skip if no connection or project is available if (shouldSkipIntegrationTest() || !connection || !projectId) { return; } // Try to get an existing pipeline ID for testing try { const pipelines = await listPipelines(connection, { projectId }); if (pipelines.length > 0) { existingPipelineId = pipelines[0].id ?? null; } } catch (error) { console.log('Could not find existing pipelines for testing:', error); } }); test('should get a pipeline by ID', async () => { // Skip if no connection, project, or pipeline ID is available if ( shouldSkipIntegrationTest() || !connection || !projectId || !existingPipelineId ) { console.log( 'Skipping getPipeline integration test - no connection, project or existing pipeline available', ); return; } // Act - make an API call to Azure DevOps const pipeline = await getPipeline(connection, { projectId, pipelineId: existingPipelineId, }); // Assert expect(pipeline).toBeDefined(); expect(pipeline.id).toBe(existingPipelineId); expect(pipeline.name).toBeDefined(); expect(typeof pipeline.name).toBe('string'); expect(pipeline.folder).toBeDefined(); expect(pipeline.revision).toBeDefined(); expect(pipeline.url).toBeDefined(); expect(pipeline.url).toContain('_apis/pipelines'); }); test('should throw ResourceNotFoundError for non-existent pipeline', async () => { // Skip if no connection or project is available if (shouldSkipIntegrationTest() || !connection || !projectId) { console.log( 'Skipping getPipeline error test - no connection or project available', ); return; } // Use a very high ID that is unlikely to exist const nonExistentPipelineId = 999999; // Act & Assert - should throw a not found error await expect( getPipeline(connection, { projectId, pipelineId: nonExistentPipelineId, }), ).rejects.toThrow(/not found/); }); }); ``` -------------------------------------------------------------------------------- /src/features/pipelines/trigger-pipeline/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError, AzureDevOpsAuthenticationError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; import { defaultProject } from '../../../utils/environment'; import { Run, TriggerPipelineOptions } from '../types'; /** * Trigger a pipeline run * * @param connection The Azure DevOps WebApi connection * @param options Options for triggering a pipeline * @returns The run details */ export async function triggerPipeline( connection: WebApi, options: TriggerPipelineOptions, ): Promise<Run> { try { const pipelinesApi = await connection.getPipelinesApi(); const { projectId = defaultProject, pipelineId, branch, variables, templateParameters, stagesToSkip, } = options; // Prepare run parameters const runParameters: Record<string, unknown> = {}; // Add variables if (variables) { runParameters.variables = variables; } // Add template parameters if (templateParameters) { runParameters.templateParameters = templateParameters; } // Add stages to skip if (stagesToSkip && stagesToSkip.length > 0) { runParameters.stagesToSkip = stagesToSkip; } // Prepare resources (including branch) const resources: Record<string, unknown> = branch ? { repositories: { self: { refName: `refs/heads/${branch}` } } } : {}; // Add resources to run parameters if not empty if (Object.keys(resources).length > 0) { runParameters.resources = resources; } // Call pipeline API to run pipeline const result = await pipelinesApi.runPipeline( runParameters, projectId, pipelineId, ); return result; } catch (error) { // Handle specific error types if (error instanceof AzureDevOpsError) { throw error; } // Check for specific error types and convert to appropriate Azure DevOps errors if (error instanceof Error) { if ( error.message.includes('Authentication') || error.message.includes('Unauthorized') || error.message.includes('401') ) { throw new AzureDevOpsAuthenticationError( `Failed to authenticate: ${error.message}`, ); } if ( error.message.includes('not found') || error.message.includes('does not exist') || error.message.includes('404') ) { throw new AzureDevOpsResourceNotFoundError( `Pipeline or project not found: ${error.message}`, ); } } // Otherwise, wrap it in a generic error throw new AzureDevOpsError( `Failed to trigger pipeline: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/feature.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import * as azureDevOpsClient from '../../../clients/azure-devops'; import { handleRequestError } from '../../../shared/errors/handle-request-error'; import { CreateWikiPageSchema } from './schema'; import { defaultOrg, defaultProject } from '../../../utils/environment'; /** * Creates a new wiki page in Azure DevOps. * If a page already exists at the specified path, it will be updated. * * @param {z.infer<typeof CreateWikiPageSchema>} params - The parameters for creating the wiki page. * @returns {Promise<any>} A promise that resolves with the API response. */ export const createWikiPage = async ( params: z.infer<typeof CreateWikiPageSchema>, client?: { defaults?: { organizationId?: string; projectId?: string }; put: ( url: string, data: Record<string, unknown>, ) => Promise<{ data: unknown }>; }, // For testing purposes only ) => { try { const { organizationId, projectId, wikiId, pagePath, content, comment } = params; // For testing mode, use the client's defaults if (client && client.defaults) { const org = organizationId ?? client.defaults.organizationId; const project = projectId ?? client.defaults.projectId; if (!org) { throw new Error( 'Organization ID is not defined. Please provide it or set a default.', ); } // This branch is for testing only const apiUrl = `${org}/${ project ? `${project}/` : '' }_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent( pagePath ?? '/', )}&api-version=7.1-preview.1`; // Prepare the request body const requestBody: Record<string, unknown> = { content }; if (comment) { requestBody.comment = comment; } // Make the API request const response = await client.put(apiUrl, requestBody); return response.data; } else { // Use default organization and project if not provided const org = organizationId ?? defaultOrg; const project = projectId ?? defaultProject; if (!org) { throw new Error( 'Organization ID is not defined. Please provide it or set a default.', ); } // Create the client const wikiClient = await azureDevOpsClient.getWikiClient({ organizationId: org, }); // Prepare the wiki page content const wikiPageContent = { content, }; // This is the real implementation return await wikiClient.updatePage( wikiPageContent, project, wikiId, pagePath ?? '/', { comment: comment ?? undefined, }, ); } } catch (error: unknown) { throw await handleRequestError( error, 'Failed to create or update wiki page', ); } }; ``` -------------------------------------------------------------------------------- /project-management/planning/tech-stack.md: -------------------------------------------------------------------------------- ```markdown ## Tech Stack Documentation ### Overview The tech stack for the Azure DevOps MCP server is tailored to ensure compatibility with the MCP, efficient interaction with Azure DevOps APIs, and a focus on security and scalability. It comprises a mix of programming languages, runtime environments, libraries, and development tools that streamline server development and operation. ### Programming Language and Runtime - **Typescript**: Selected for its type safety, which minimizes runtime errors and enhances code readability. It aligns seamlessly with the MCP Typescript SDK for easy integration. - **Node.js**: The runtime environment for executing Typescript, offering a non-blocking, event-driven architecture ideal for handling multiple API requests efficiently. ### Libraries and Dependencies - **MCP Typescript SDK**: The official SDK for MCP server development. It provides the `getMcpServer` function to define and run the server with minimal setup, managing socket connections and JSON-RPC messaging so developers can focus on tool logic. - **azure-devops-node-api**: A Node.js library that simplifies interaction with Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). It supports Personal Access Token (PAT) authentication and offers a straightforward interface for common tasks. - **Axios**: A promise-based HTTP client for raw API requests, particularly useful for endpoints not covered by `azure-devops-node-api` (e.g., listing organizations or Search API). It also supports Azure Active Directory (AAD) token-based authentication. - **@azure/identity**: Facilitates AAD token acquisition for secure authentication with Azure DevOps resources when using AAD-based methods. - **dotenv**: A lightweight module for loading environment variables from a `.env` file, securely managing sensitive data like PATs and AAD credentials. ### Development Tools - **Visual Studio Code (VS Code)**: The recommended IDE, offering robust Typescript support, debugging tools, and integration with Git and Azure DevOps. - **npm**: The package manager for installing and managing project dependencies. - **ts-node**: Enables direct execution of Typescript files without precompilation, accelerating development and testing workflows. ### Testing and Quality Assurance - **Jest**: A widely-used testing framework for unit and integration tests, ensuring the reliability of tools and server functionality. - **ESLint**: A linter configured with Typescript-specific rules to maintain code quality and consistency. - **Prettier**: A code formatter to enforce a uniform style across the project. ### Version Control and CI/CD - **Git**: Used for version control, with repositories hosted on GitHub or Azure DevOps. - **GitHub Actions**: Automates continuous integration and deployment, including builds, tests, and releases. --- ``` -------------------------------------------------------------------------------- /create_branch.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # --- Configuration --- # Set the default remote name (usually 'origin') REMOTE_NAME="origin" # Set to 'true' if you want to force delete (-D) unmerged stale branches. # Set to 'false' to use safe delete (-d) which requires branches to be merged. FORCE_DELETE_STALE=false # --------------------- # Check if a branch name was provided as an argument if [ -z "$1" ]; then echo "Error: No branch name specified." echo "Usage: $0 <new-branch-name>" exit 1 fi NEW_BRANCH_NAME="$1" # --- Pruning Section --- echo "--- Pruning stale branches ---" # 1. Update from remote and prune remote-tracking branches that no longer exist on the remote echo "Fetching updates from '$REMOTE_NAME' and pruning remote-tracking refs..." git fetch --prune "$REMOTE_NAME" echo "Fetch and prune complete." echo # 2. Identify and delete local branches whose upstream is gone echo "Checking for local branches tracking deleted remote branches..." # Get list of local branches marked as 'gone' relative to the specified remote # Use awk to correctly extract the branch name, handling the '*' for the current branch GONE_BRANCHES=$(git branch -vv | grep "\[$REMOTE_NAME/.*: gone\]" | awk '/^\*/ {print $2} ! /^\*/ {print $1}') if [ -z "$GONE_BRANCHES" ]; then echo "No stale local branches found to delete." else echo "Found stale local branches:" echo "$GONE_BRANCHES" echo DELETE_CMD="git branch -d" if [ "$FORCE_DELETE_STALE" = true ]; then echo "Attempting to force delete (-D) stale local branches..." DELETE_CMD="git branch -D" else echo "Attempting to safely delete (-d) stale local branches (will skip unmerged branches)..." fi # Loop through and delete each branch, handling potential errors echo "$GONE_BRANCHES" | while IFS= read -r branch; do # Check if the branch to be deleted is the current branch CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "$CURRENT_BRANCH" ]; then echo "Skipping deletion of '$branch' because it is the current branch." continue fi echo "Deleting local branch '$branch'..." # Use the chosen delete command (-d or -D) $DELETE_CMD "$branch" done echo "Stale branch cleanup finished." fi echo "--- Pruning complete ---" echo # --- Branch Creation Section --- echo "Creating and checking out new branch: '$NEW_BRANCH_NAME'..." git checkout -b "$NEW_BRANCH_NAME" # Check if checkout was successful (it might fail if the branch already exists locally) if [ $? -ne 0 ]; then echo "Error: Failed to create or checkout branch '$NEW_BRANCH_NAME'." echo "It might already exist locally." exit 1 fi echo "" echo "Successfully created and switched to branch '$NEW_BRANCH_NAME'." # Optional: Suggest pushing and setting upstream # echo "To push and set the upstream: git push -u $REMOTE_NAME $NEW_BRANCH_NAME" exit 0 ``` -------------------------------------------------------------------------------- /src/features/search/search-wiki/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { searchWiki } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('searchWiki integration', () => { let connection: WebApi | null = null; let projectName: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test('should search wiki content', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Search the wiki const result = await searchWiki(connection, { searchText: 'test', projectId: projectName, top: 10, }); // Verify the result expect(result).toBeDefined(); expect(result.count).toBeDefined(); expect(Array.isArray(result.results)).toBe(true); if (result.results.length > 0) { expect(result.results[0].fileName).toBeDefined(); expect(result.results[0].path).toBeDefined(); expect(result.results[0].project).toBeDefined(); } }); test('should handle pagination correctly', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get first page of results const page1 = await searchWiki(connection, { searchText: 'test', // Common word likely to have many results projectId: projectName, top: 5, skip: 0, }); // Get second page of results const page2 = await searchWiki(connection, { searchText: 'test', projectId: projectName, top: 5, skip: 5, }); // Verify pagination expect(page1.results).not.toEqual(page2.results); }); test('should handle filters correctly', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // This test is more of a smoke test since we can't guarantee specific projects const result = await searchWiki(connection, { searchText: 'test', filters: { Project: [projectName], }, includeFacets: true, }); expect(result).toBeDefined(); expect(result.facets).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@tiberriver256/mcp-server-azure-devops", "version": "0.1.42", "description": "Azure DevOps reference server for the Model Context Protocol (MCP)", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { "mcp-server-azure-devops": "./dist/index.js" }, "files": [ "dist/", "docs/", "LICENSE", "README.md" ], "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } }, "lint-staged": { "*.ts": [ "prettier --write", "eslint --fix" ] }, "release-please": { "release-type": "node", "changelog-types": [ { "type": "feat", "section": "Features", "hidden": false }, { "type": "fix", "section": "Bug Fixes", "hidden": false }, { "type": "chore", "section": "Miscellaneous", "hidden": false }, { "type": "docs", "section": "Documentation", "hidden": false }, { "type": "perf", "section": "Performance Improvements", "hidden": false }, { "type": "refactor", "section": "Code Refactoring", "hidden": false } ] }, "scripts": { "build": "tsc", "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "start": "node dist/index.js", "inspector": "npm run build && npx @modelcontextprotocol/[email protected] node dist/index.js", "test": "npm run test:unit && npm run test:int && npm run test:e2e", "test:unit": "jest --config jest.unit.config.js", "test:int": "jest --config jest.int.config.js", "test:e2e": "jest --config jest.e2e.config.js", "test:watch": "jest --watch", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "prepare": "husky install", "commit": "cz" }, "keywords": [ "azure-devops", "mcp", "ai", "automation" ], "author": "", "license": "MIT", "dependencies": { "@azure/identity": "^4.8.0", "@modelcontextprotocol/sdk": "^1.6.0", "axios": "^1.8.3", "azure-devops-node-api": "^13.0.0", "dotenv": "^16.3.1", "minimatch": "^10.0.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^8.27.0", "@typescript-eslint/parser": "^8.27.0", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^8.0.0", "husky": "^8.0.3", "jest": "^29.0.0", "lint-staged": "^15.5.0", "prettier": "^3.0.0", "ts-jest": "^29.0.0", "ts-node-dev": "^2.0.0", "typescript": "^5.8.2" } } ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError, AzureDevOpsValidationError, } from '../../../shared/errors'; import { WikiType } from './schema'; import { getWikiClient } from '../../../clients/azure-devops'; /** * Options for creating a wiki */ export interface CreateWikiOptions { /** * The ID or name of the organization * If not provided, the default organization will be used */ organizationId?: string; /** * The ID or name of the project * If not provided, the default project will be used */ projectId?: string; /** * The name of the new wiki */ name: string; /** * Type of wiki to create (projectWiki or codeWiki) * Default is projectWiki */ type?: WikiType; /** * The ID of the repository to associate with the wiki * Required when type is codeWiki */ repositoryId?: string; /** * Folder path inside repository which is shown as Wiki * Only applicable for codeWiki type * Default is '/' */ mappedPath?: string; } /** * Create a new wiki in Azure DevOps * * @param _connection The Azure DevOps WebApi connection (deprecated, kept for backward compatibility) * @param options Options for creating a wiki * @returns The created wiki * @throws {AzureDevOpsValidationError} When required parameters are missing * @throws {AzureDevOpsResourceNotFoundError} When the project or repository is not found * @throws {AzureDevOpsPermissionError} When the user does not have permission to create a wiki * @throws {AzureDevOpsError} When an error occurs while creating the wiki */ export async function createWiki( _connection: WebApi, options: CreateWikiOptions, ) { try { const { name, projectId, type = WikiType.ProjectWiki, repositoryId, mappedPath = '/', } = options; // Validate repository ID for code wiki if (type === WikiType.CodeWiki && !repositoryId) { throw new AzureDevOpsValidationError( 'Repository ID is required for code wikis', ); } // Get the Wiki client const wikiClient = await getWikiClient({ organizationId: options.organizationId, }); // Prepare the wiki creation parameters const wikiCreateParams = { name, projectId: projectId!, type, ...(type === WikiType.CodeWiki && { repositoryId, mappedPath, version: { version: 'main', versionType: 'branch' as const, }, }), }; // Create the wiki return await wikiClient.createWiki(projectId!, wikiCreateParams); } catch (error) { // Just rethrow if it's already one of our error types if (error instanceof AzureDevOpsError) { throw error; } // Otherwise wrap in AzureDevOpsError throw new AzureDevOpsError( `Failed to create wiki: ${error instanceof Error ? error.message : String(error)}`, ); } } ```