This is page 3 of 6. Use http://codebase.md/tiberriver256/azure-devops-mcp?lines=false&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 -------------------------------------------------------------------------------- /src/features/search/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { isSearchRequest, handleSearchRequest } from './index'; import { searchCode } from './search-code'; import { searchWiki } from './search-wiki'; import { searchWorkItems } from './search-work-items'; // Mock the imported modules jest.mock('./search-code', () => ({ searchCode: jest.fn(), })); jest.mock('./search-wiki', () => ({ searchWiki: jest.fn(), })); jest.mock('./search-work-items', () => ({ searchWorkItems: jest.fn(), })); describe('Search Request Handlers', () => { const mockConnection = {} as WebApi; describe('isSearchRequest', () => { it('should return true for search requests', () => { const validTools = ['search_code', 'search_wiki', 'search_work_items']; validTools.forEach((tool) => { const request = { params: { name: tool, arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isSearchRequest(request)).toBe(true); }); }); it('should return false for non-search requests', () => { const request = { params: { name: 'list_projects', arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isSearchRequest(request)).toBe(false); }); }); describe('handleSearchRequest', () => { it('should handle search_code request', async () => { const mockSearchResults = { count: 2, results: [ { fileName: 'file1.ts', path: '/path/to/file1.ts' }, { fileName: 'file2.ts', path: '/path/to/file2.ts' }, ], }; (searchCode as jest.Mock).mockResolvedValue(mockSearchResults); const request = { params: { name: 'search_code', arguments: { searchText: 'function', projectId: 'project1', }, }, method: 'tools/call', } as CallToolRequest; const response = await handleSearchRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockSearchResults, ); expect(searchCode).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ searchText: 'function', projectId: 'project1', }), ); }); it('should handle search_wiki request', async () => { const mockSearchResults = { count: 1, results: [{ title: 'Wiki Page', path: '/path/to/page' }], }; (searchWiki as jest.Mock).mockResolvedValue(mockSearchResults); const request = { params: { name: 'search_wiki', arguments: { searchText: 'documentation', projectId: 'project1', }, }, method: 'tools/call', } as CallToolRequest; const response = await handleSearchRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockSearchResults, ); expect(searchWiki).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ searchText: 'documentation', projectId: 'project1', }), ); }); it('should handle search_work_items request', async () => { const mockSearchResults = { count: 2, results: [ { id: 1, title: 'Bug 1' }, { id: 2, title: 'Feature 2' }, ], }; (searchWorkItems as jest.Mock).mockResolvedValue(mockSearchResults); const request = { params: { name: 'search_work_items', arguments: { searchText: 'bug', projectId: 'project1', }, }, method: 'tools/call', } as CallToolRequest; const response = await handleSearchRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockSearchResults, ); expect(searchWorkItems).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ searchText: 'bug', projectId: 'project1', }), ); }); it('should throw error for unknown tool', async () => { const request = { params: { name: 'unknown_tool', arguments: {}, }, method: 'tools/call', } as CallToolRequest; await expect( handleSearchRequest(mockConnection, request), ).rejects.toThrow('Unknown search tool'); }); it('should propagate errors from search functions', async () => { const mockError = new Error('Test error'); (searchCode as jest.Mock).mockRejectedValue(mockError); const request = { params: { name: 'search_code', arguments: { searchText: 'function', }, }, method: 'tools/call', } as CallToolRequest; await expect( handleSearchRequest(mockConnection, request), ).rejects.toThrow(mockError); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/auth/auth-factory.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; import { BearerCredentialHandler } from 'azure-devops-node-api/handlers/bearertoken'; import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; import { AzureDevOpsAuthenticationError } from '../errors'; /** * Authentication methods supported by the Azure DevOps client */ export enum AuthenticationMethod { /** * Personal Access Token authentication */ PersonalAccessToken = 'pat', /** * Azure Identity authentication (DefaultAzureCredential) */ AzureIdentity = 'azure-identity', /** * Azure CLI authentication (AzureCliCredential) */ AzureCli = 'azure-cli', } /** * Authentication configuration for Azure DevOps */ export interface AuthConfig { /** * Authentication method to use */ method: AuthenticationMethod; /** * Organization URL (e.g., https://dev.azure.com/myorg) */ organizationUrl: string; /** * Personal Access Token for Azure DevOps (required for PAT authentication) */ personalAccessToken?: string; } /** * Azure DevOps resource ID for token acquisition */ const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; /** * Creates an authenticated client for Azure DevOps API based on the specified authentication method * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ export async function createAuthClient(config: AuthConfig): Promise<WebApi> { if (!config.organizationUrl) { throw new AzureDevOpsAuthenticationError('Organization URL is required'); } try { let client: WebApi; switch (config.method) { case AuthenticationMethod.PersonalAccessToken: client = await createPatClient(config); break; case AuthenticationMethod.AzureIdentity: client = await createAzureIdentityClient(config); break; case AuthenticationMethod.AzureCli: client = await createAzureCliClient(config); break; default: throw new AzureDevOpsAuthenticationError( `Unsupported authentication method: ${config.method}`, ); } // Test the connection const locationsApi = await client.getLocationsApi(); await locationsApi.getResourceAreas(); return client; } catch (error) { if (error instanceof AzureDevOpsAuthenticationError) { throw error; } throw new AzureDevOpsAuthenticationError( `Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Creates a client using Personal Access Token authentication * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If PAT is missing or authentication fails */ async function createPatClient(config: AuthConfig): Promise<WebApi> { if (!config.personalAccessToken) { throw new AzureDevOpsAuthenticationError( 'Personal Access Token is required', ); } // Create authentication handler using PAT const authHandler = getPersonalAccessTokenHandler(config.personalAccessToken); // Create API client with the auth handler return new WebApi(config.organizationUrl, authHandler); } /** * Creates a client using DefaultAzureCredential authentication * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If token acquisition fails */ async function createAzureIdentityClient(config: AuthConfig): Promise<WebApi> { try { // Create DefaultAzureCredential const credential = new DefaultAzureCredential(); // Get token for Azure DevOps const token = await credential.getToken( `${AZURE_DEVOPS_RESOURCE_ID}/.default`, ); if (!token || !token.token) { throw new Error('Failed to acquire token'); } // Create bearer token handler const authHandler = new BearerCredentialHandler(token.token); // Create API client with the auth handler return new WebApi(config.organizationUrl, authHandler); } catch (error) { throw new AzureDevOpsAuthenticationError( `Failed to acquire Azure Identity token: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Creates a client using AzureCliCredential authentication * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If token acquisition fails */ async function createAzureCliClient(config: AuthConfig): Promise<WebApi> { try { // Create AzureCliCredential const credential = new AzureCliCredential(); // Get token for Azure DevOps const token = await credential.getToken( `${AZURE_DEVOPS_RESOURCE_ID}/.default`, ); if (!token || !token.token) { throw new Error('Failed to acquire token'); } // Create bearer token handler const authHandler = new BearerCredentialHandler(token.token); // Create API client with the auth handler return new WebApi(config.organizationUrl, authHandler); } catch (error) { throw new AzureDevOpsAuthenticationError( `Failed to acquire Azure CLI token: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/pull-requests/list-pull-requests/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { PullRequest } from '../types'; import { listPullRequests } from './feature'; import { createPullRequest } from '../create-pull-request/feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '../../../shared/test/test-helpers'; describe('listPullRequests integration', () => { let connection: WebApi | null = null; let testPullRequest: PullRequest | null = null; let projectName: string; let repositoryName: string; // Generate unique branch name and PR title using timestamp const timestamp = Date.now(); const randomSuffix = Math.floor(Math.random() * 1000); const uniqueBranchName = `test-branch-${timestamp}-${randomSuffix}`; const uniqueTitle = `Test PR ${timestamp}-${randomSuffix}`; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); // Set up project and repository names from environment projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || ''; // Skip setup if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { return; } }); afterAll(async () => { // Clean up created resources if needed if ( testPullRequest && testPullRequest.pullRequestId && !shouldSkipIntegrationTest() ) { try { // Abandon the test pull request if it was created const gitApi = await connection?.getGitApi(); if (gitApi) { await gitApi.updatePullRequest( { status: 2, // 2 = Abandoned }, repositoryName, testPullRequest.pullRequestId, projectName, ); } } catch (error) { console.error('Error cleaning up test pull request:', error); } } }); test('should list pull requests from repository', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } try { // Create a branch for testing const gitApi = await connection.getGitApi(); // Get the default branch info const repository = await gitApi.getRepository( repositoryName, projectName, ); if (!repository || !repository.defaultBranch) { throw new Error('Cannot find repository or default branch'); } // Get the commit to branch from const commits = await gitApi.getCommits( repositoryName, { itemVersion: { versionType: 0, // commit version: repository.defaultBranch.replace('refs/heads/', ''), }, $top: 1, }, projectName, ); if (!commits || commits.length === 0) { throw new Error('Cannot find commits in repository'); } // Create a new branch const refUpdate = { name: `refs/heads/${uniqueBranchName}`, oldObjectId: '0000000000000000000000000000000000000000', newObjectId: commits[0].commitId, }; const updateResult = await gitApi.updateRefs( [refUpdate], repositoryName, projectName, ); if ( !updateResult || updateResult.length === 0 || !updateResult[0].success ) { throw new Error('Failed to create new branch'); } // Create a test pull request testPullRequest = await createPullRequest( connection, projectName, repositoryName, { title: uniqueTitle, description: 'Test pull request for integration testing', sourceRefName: `refs/heads/${uniqueBranchName}`, targetRefName: repository.defaultBranch, isDraft: true, }, ); // List pull requests const pullRequests = await listPullRequests( connection, projectName, repositoryName, { projectId: projectName, repositoryId: repositoryName }, ); // Verify expect(pullRequests).toBeDefined(); expect(pullRequests.value).toBeDefined(); expect(Array.isArray(pullRequests.value)).toBe(true); expect(typeof pullRequests.count).toBe('number'); expect(typeof pullRequests.hasMoreResults).toBe('boolean'); // Find our test PR in the list const foundPR = pullRequests.value.find( (pr) => pr.pullRequestId === testPullRequest?.pullRequestId, ); expect(foundPR).toBeDefined(); expect(foundPR?.title).toBe(uniqueTitle); // Test with filters const filteredPRs = await listPullRequests( connection, projectName, repositoryName, { projectId: projectName, repositoryId: repositoryName, status: 'active', top: 5, }, ); expect(filteredPRs).toBeDefined(); expect(filteredPRs.value).toBeDefined(); expect(Array.isArray(filteredPRs.value)).toBe(true); expect(filteredPRs.count).toBeGreaterThanOrEqual(0); } catch (error) { console.error('Test error:', error); throw error; } }, 30000); // 30 second timeout for integration test }); ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import axios from 'axios'; import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, AzureDevOpsValidationError, AzureDevOpsPermissionError, } from '../../../shared/errors'; import { SearchWorkItemsOptions, WorkItemSearchRequest, WorkItemSearchResponse, } from '../types'; /** * Search for work items in Azure DevOps projects * * @param connection The Azure DevOps WebApi connection * @param options Parameters for searching work items * @returns Search results with work item details and highlights */ export async function searchWorkItems( connection: WebApi, options: SearchWorkItemsOptions, ): Promise<WorkItemSearchResponse> { try { // Prepare the search request const searchRequest: WorkItemSearchRequest = { searchText: options.searchText, $skip: options.skip, $top: options.top, filters: { ...(options.projectId ? { 'System.TeamProject': [options.projectId] } : {}), ...options.filters, }, includeFacets: options.includeFacets, $orderBy: options.orderBy, }; // Get the authorization header from the connection const authHeader = await getAuthorizationHeader(); // Extract organization and project from the connection URL const { organization, project } = extractOrgAndProject( connection, options.projectId, ); // Make the search API request // If projectId is provided, include it in the URL, otherwise perform organization-wide search const searchUrl = options.projectId ? `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/workitemsearchresults?api-version=7.1` : `https://almsearch.dev.azure.com/${organization}/_apis/search/workitemsearchresults?api-version=7.1`; const searchResponse = await axios.post<WorkItemSearchResponse>( searchUrl, searchRequest, { headers: { Authorization: authHeader, 'Content-Type': 'application/json', }, }, ); return searchResponse.data; } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Handle axios errors if (axios.isAxiosError(error)) { const status = error.response?.status; const message = error.response?.data?.message || error.message; if (status === 404) { throw new AzureDevOpsResourceNotFoundError( `Resource not found: ${message}`, ); } else if (status === 400) { throw new AzureDevOpsValidationError( `Invalid request: ${message}`, error.response?.data, ); } else if (status === 401 || status === 403) { throw new AzureDevOpsPermissionError(`Permission denied: ${message}`); } else { // For other axios errors, wrap in a generic AzureDevOpsError throw new AzureDevOpsError(`Azure DevOps API error: ${message}`); } // This code is unreachable but TypeScript doesn't know that } // Otherwise, wrap it in a generic error throw new AzureDevOpsError( `Failed to search work items: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Extract organization and project from the connection URL * * @param connection The Azure DevOps WebApi connection * @param projectId The project ID or name (optional) * @returns The organization and project */ function extractOrgAndProject( connection: WebApi, projectId?: string, ): { organization: string; project: string } { // Extract organization from the connection URL const url = connection.serverUrl; const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); const organization = match ? match[1] : ''; if (!organization) { throw new AzureDevOpsValidationError( 'Could not extract organization from connection URL', ); } return { organization, project: projectId || '', }; } /** * Get the authorization header from the connection * * @returns The authorization header */ async function getAuthorizationHeader(): Promise<string> { try { // For PAT authentication, we can construct the header directly if ( process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && process.env.AZURE_DEVOPS_PAT ) { // For PAT auth, we can construct the Basic auth header directly const token = process.env.AZURE_DEVOPS_PAT; const base64Token = Buffer.from(`:${token}`).toString('base64'); return `Basic ${base64Token}`; } // For Azure Identity / Azure CLI auth, we need to get a token // using the Azure DevOps resource ID // Choose the appropriate credential based on auth method const credential = process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' ? new AzureCliCredential() : new DefaultAzureCredential(); // Azure DevOps resource ID for token acquisition const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; // Get token for Azure DevOps const token = await credential.getToken( `${AZURE_DEVOPS_RESOURCE_ID}/.default`, ); if (!token || !token.token) { throw new Error('Failed to acquire token for Azure DevOps'); } return `Bearer ${token.token}`; } catch (error) { throw new AzureDevOpsValidationError( `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /docs/tools/wiki.md: -------------------------------------------------------------------------------- ```markdown # Azure DevOps Wiki Tools This document describes the tools available for working with Azure DevOps wikis. ## get_wikis Lists all wikis in a project or organization. ### Description The `get_wikis` tool retrieves all wikis available in a specified Azure DevOps project or organization. This is useful for discovering which wikis are available before working with specific wiki pages. ### Parameters - `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used. - `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used. ```json { "organizationId": "MyOrganization", "projectId": "MyProject" } ``` ### Response The tool returns an array of wiki objects, each containing: - `id`: The unique identifier of the wiki - `name`: The name of the wiki - `url`: The URL of the wiki - Other wiki properties such as `remoteUrl` and `type` Example response: ```json [ { "id": "wiki1-id", "name": "MyWiki", "type": "projectWiki", "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki", "remoteUrl": "https://dev.azure.com/MyOrganization/MyProject/_git/MyWiki" } ] ``` ## get_wiki_page Gets the content of a specific wiki page. ### Description The `get_wiki_page` tool retrieves the content of a specified wiki page as plain text. This is useful for viewing the content of wiki pages programmatically. ### Parameters - `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used. - `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used. - `wikiId` (required): The ID or name of the wiki containing the page. - `pagePath` (required): The path of the page within the wiki (e.g., "/Home" or "/Folder/Page"). ```json { "organizationId": "MyOrganization", "projectId": "MyProject", "wikiId": "MyWiki", "pagePath": "/Home" } ``` ### Response The tool returns the content of the wiki page as a string in markdown format. Example response: ```markdown # Welcome to the Wiki This is the home page of the wiki. ## Getting Started Here are some links to help you get started: - [Documentation](/Documentation) - [Tutorials](/Tutorials) - [FAQ](/FAQ) ``` ### Error Handling The tool may throw the following errors: - `AzureDevOpsResourceNotFoundError`: If the specified wiki or page does not exist - `AzureDevOpsPermissionError`: If the authenticated user does not have permission to access the wiki - General errors: If other unexpected errors occur during the request ### Example Usage ```typescript // Example MCP client call const result = await mcpClient.callTool('get_wiki_page', { projectId: 'MyProject', wikiId: 'MyWiki', pagePath: '/Home' }); console.log(result); ``` ### Implementation Details This tool uses the Azure DevOps REST API to retrieve the wiki page content with the `Accept: text/plain` header to get the content directly in text format. The page path is properly encoded to handle spaces and special characters in the URL. ## list_wiki_pages Lists all pages within a specified Azure DevOps wiki. ### Description The `list_wiki_pages` tool retrieves a list of all pages within a specified wiki. It returns summary information for each page, including the page ID, path, URL, and order. This is useful for discovering the structure and contents of a wiki before working with specific pages. ### Parameters - `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used. - `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used. - `wikiId` (required): The ID or name of the wiki to list pages from. ```json { "organizationId": "MyOrganization", "projectId": "MyProject", "wikiId": "MyWiki" } ``` ### Response The tool returns an array of wiki page summary objects, each containing: - `id`: The unique numeric identifier of the page - `path`: The path of the page within the wiki (e.g., "/Home" or "/Folder/Page") - `url`: The URL to access the page (optional) - `order`: The display order of the page (optional) Example response: ```json [ { "id": 1, "path": "/Home", "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/1/Home", "order": 0 }, { "id": 2, "path": "/Documentation", "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/2/Documentation", "order": 1 }, { "id": 3, "path": "/Documentation/Getting-Started", "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/3/Getting-Started", "order": 2 } ] ``` ### Error Handling The tool may throw the following errors: - `AzureDevOpsResourceNotFoundError`: If the specified wiki does not exist - `AzureDevOpsPermissionError`: If the authenticated user does not have permission to access the wiki - `AzureDevOpsError`: If other unexpected errors occur during the request ### Example Usage ```typescript // Example MCP client call const result = await mcpClient.callTool('list_wiki_pages', { projectId: 'MyProject', wikiId: 'MyWiki' }); console.log(result); ``` ### Implementation Details This tool uses the Azure DevOps REST API to retrieve the list of pages within a wiki. The response is mapped to provide a consistent interface with page ID, path, URL, and order information. ``` -------------------------------------------------------------------------------- /docs/azure-identity-authentication.md: -------------------------------------------------------------------------------- ```markdown # Azure Identity Authentication for Azure DevOps MCP Server This guide explains how to use Azure Identity authentication with the Azure DevOps MCP Server. ## Overview Azure Identity authentication lets you use your existing Azure credentials to authenticate with Azure DevOps, instead of creating and managing Personal Access Tokens (PATs). This approach offers several benefits: - **Unified authentication**: Use the same credentials for Azure and Azure DevOps - **Enhanced security**: Support for managed identities and client certificates - **Flexible credential types**: Multiple options for different environments - **Automatic token management**: Handles token acquisition and renewal ## Credential Types The Azure DevOps MCP Server supports multiple credential types through the Azure Identity SDK: ### DefaultAzureCredential This credential type attempts multiple authentication methods in sequence until one succeeds: 1. Environment variables (EnvironmentCredential) 2. Managed Identity (ManagedIdentityCredential) 3. Azure CLI (AzureCliCredential) 4. Visual Studio Code (VisualStudioCodeCredential) 5. Azure PowerShell (AzurePowerShellCredential) It's a great option for applications that need to work across different environments without code changes. ### AzureCliCredential This credential type uses your Azure CLI login. It's perfect for local development when you're already using the Azure CLI. ## Configuration ### Environment Variables To use Azure Identity authentication, set the following environment variables: ```bash # Required AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_AUTH_METHOD=azure-identity # Optional AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name ``` For service principal authentication, add these environment variables: ```bash AZURE_TENANT_ID=your-tenant-id AZURE_CLIENT_ID=your-client-id AZURE_CLIENT_SECRET=your-client-secret ``` ### Use with Claude Desktop/Cursor AI Add the following to your configuration file: ```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" } } } } ``` ## Authentication Methods ### Method 1: Using Azure CLI 1. Install the Azure CLI from [here](https://docs.microsoft.com/cli/azure/install-azure-cli) 2. Log in to Azure: ```bash az login ``` 3. Set up your environment variables: ```bash AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_AUTH_METHOD=azure-identity ``` ### Method 2: Using Service Principal 1. Create a service principal in Azure AD: ```bash az ad sp create-for-rbac --name "MyAzureDevOpsApp" ``` 2. Grant the service principal access to your Azure DevOps organization 3. Set up your environment variables: ```bash AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_AUTH_METHOD=azure-identity AZURE_TENANT_ID=your-tenant-id AZURE_CLIENT_ID=your-client-id AZURE_CLIENT_SECRET=your-client-secret ``` ### Method 3: Using Managed Identity (for Azure-hosted applications) 1. Enable managed identity for your Azure resource (VM, App Service, etc.) 2. Grant the managed identity access to your Azure DevOps organization 3. Set up your environment variables: ```bash AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_AUTH_METHOD=azure-identity ``` ## Troubleshooting ### Common Issues #### Failed to acquire token ``` Error: Failed to authenticate with Azure Identity: CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token ``` **Possible solutions:** - Ensure you're logged in with `az login` - Check if your managed identity is correctly configured - Verify that service principal credentials are correct #### Permission issues ``` Error: Failed to authenticate with Azure Identity: AuthorizationFailed: The client does not have authorization to perform action ``` **Possible solutions:** - Ensure your identity has the necessary permissions in Azure DevOps - Check if you need to add your identity to specific Azure DevOps project(s) #### Network issues ``` Error: Failed to authenticate with Azure Identity: ClientAuthError: Interaction required ``` **Possible solutions:** - Check your network connectivity - Verify that your firewall allows connections to Azure services ## Best Practices 1. **Choose the right credential type for your environment**: - For local development: Azure CLI credential - For CI/CD pipelines: Service principal - For Azure-hosted applications: Managed identity 2. **Follow the principle of least privilege**: - Only grant the permissions needed for your use case - Regularly audit and review permissions 3. **Rotate credentials regularly**: - For service principals, rotate client secrets periodically - Use certificate-based authentication when possible for enhanced security ## Examples ### Basic configuration with Azure CLI ```bash AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_AUTH_METHOD=azure-identity AZURE_DEVOPS_DEFAULT_PROJECT=MyProject ``` ### Service principal authentication ```bash AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_AUTH_METHOD=azure-identity AZURE_DEVOPS_DEFAULT_PROJECT=MyProject AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000 AZURE_CLIENT_ID=11111111-1111-1111-1111-111111111111 AZURE_CLIENT_SECRET=your-client-secret ``` ``` -------------------------------------------------------------------------------- /src/features/pull-requests/add-pull-request-comment/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { addPullRequestComment } from './feature'; import { listPullRequests } from '../list-pull-requests/feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('addPullRequestComment integration', () => { let connection: WebApi | null = null; let projectName: string; let repositoryName: string; let pullRequestId: number; // Generate unique identifiers using timestamp for comment content const timestamp = Date.now(); const randomSuffix = Math.floor(Math.random() * 1000); beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); // Set up project and repository names from environment projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || ''; // Skip setup if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { return; } try { // Find an active pull request to use for testing const pullRequests = await listPullRequests( connection, projectName, repositoryName, { projectId: projectName, repositoryId: repositoryName, status: 'active', top: 1, }, ); if (!pullRequests || pullRequests.value.length === 0) { throw new Error('No active pull requests found for testing'); } pullRequestId = pullRequests.value[0].pullRequestId!; console.log(`Using existing pull request #${pullRequestId} for testing`); } catch (error) { console.error('Error in test setup:', error); throw error; } }); test('should add a new comment thread to pull request', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } const commentContent = `Test comment ${timestamp}-${randomSuffix}`; const result = await addPullRequestComment( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, content: commentContent, status: 'active', }, ); // Verify the comment was created expect(result.comment).toBeDefined(); expect(result.comment.content).toBe(commentContent); expect(result.thread).toBeDefined(); expect(result.thread!.status).toBe('active'); // Transformed to string }, 30000); // 30 second timeout for integration test test('should add a file comment to pull request', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } const commentContent = `File comment ${timestamp}-${randomSuffix}`; const filePath = '/README.md'; // Assuming README.md exists in the repo const lineNumber = 1; const result = await addPullRequestComment( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, content: commentContent, filePath, lineNumber, status: 'active', }, ); // Verify the file comment was created expect(result.comment).toBeDefined(); expect(result.comment.content).toBe(commentContent); expect(result.thread).toBeDefined(); expect(result.thread!.threadContext).toBeDefined(); expect(result.thread!.threadContext!.filePath).toBe(filePath); expect(result.thread!.threadContext!.rightFileStart!.line).toBe(lineNumber); }, 30000); // 30 second timeout for integration test test('should add a reply to an existing comment thread', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } // First create a thread const initialComment = await addPullRequestComment( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, content: `Initial comment ${timestamp}-${randomSuffix}`, status: 'active', }, ); const threadId = initialComment.thread!.id!; const replyContent = `Reply comment ${timestamp}-${randomSuffix}`; // Add a reply to the thread const result = await addPullRequestComment( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, content: replyContent, threadId, }, ); // Verify the reply was created expect(result.comment).toBeDefined(); expect(result.comment.content).toBe(replyContent); expect(result.thread).toBeUndefined(); // No thread returned for replies }, 30000); // 30 second timeout for integration test }); ``` -------------------------------------------------------------------------------- /src/features/work-items/create-work-item/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { createWorkItem } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; import { CreateWorkItemOptions } from '../types'; describe('createWorkItem integration', () => { let connection: WebApi | null = null; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); }); test('should create a new work item 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', ); } // Create a unique title using timestamp to avoid conflicts const uniqueTitle = `Test Work Item ${new Date().toISOString()}`; // For a true integration test, use a real project const projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; const workItemType = 'Task'; // Assumes 'Task' type exists in the project const options: CreateWorkItemOptions = { title: uniqueTitle, description: 'This is a test work item created by an integration test', priority: 2, }; // Act - make an actual API call to Azure DevOps const result = await createWorkItem( connection, projectName, workItemType, options, ); // Assert on the actual response expect(result).toBeDefined(); expect(result.id).toBeDefined(); // Verify fields match what we set expect(result.fields).toBeDefined(); if (result.fields) { expect(result.fields['System.Title']).toBe(uniqueTitle); expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(2); } }); test('should create a work item with additional fields', 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', ); } // Create a unique title using timestamp to avoid conflicts const uniqueTitle = `Test Work Item with Fields ${new Date().toISOString()}`; // For a true integration test, use a real project const projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; const workItemType = 'Task'; const options: CreateWorkItemOptions = { title: uniqueTitle, description: 'This is a test work item with additional fields', priority: 1, additionalFields: { 'System.Tags': 'Integration Test,Automated', }, }; // Act - make an actual API call to Azure DevOps const result = await createWorkItem( connection, projectName, workItemType, options, ); // Assert on the actual response expect(result).toBeDefined(); expect(result.id).toBeDefined(); // Verify fields match what we set expect(result.fields).toBeDefined(); if (result.fields) { expect(result.fields['System.Title']).toBe(uniqueTitle); expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(1); // Just check that tags contain both values, order may vary expect(result.fields['System.Tags']).toContain('Integration Test'); expect(result.fields['System.Tags']).toContain('Automated'); } }); test('should create a child work item with parent-child relationship', 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', ); } // For a true integration test, use a real project const projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; // First, create a parent work item (User Story) const parentTitle = `Parent Story ${new Date().toISOString()}`; const parentOptions: CreateWorkItemOptions = { title: parentTitle, description: 'This is a parent user story', }; const parentResult = await createWorkItem( connection, projectName, 'User Story', // Assuming User Story type exists parentOptions, ); expect(parentResult).toBeDefined(); expect(parentResult.id).toBeDefined(); const parentId = parentResult.id; // Now create a child work item (Task) with a link to the parent const childTitle = `Child Task ${new Date().toISOString()}`; const childOptions: CreateWorkItemOptions = { title: childTitle, description: 'This is a child task of a user story', parentId: parentId, // Reference to parent work item }; const childResult = await createWorkItem( connection, projectName, 'Task', childOptions, ); // Assert the child work item was created expect(childResult).toBeDefined(); expect(childResult.id).toBeDefined(); // Now verify the parent-child relationship // We would need to fetch the relations, but for now we'll just assert // that the response indicates a relationship was created expect(childResult.relations).toBeDefined(); // Check that at least one relation exists that points to our parent const parentRelation = childResult.relations?.find( (relation) => relation.rel === 'System.LinkTypes.Hierarchy-Reverse' && relation.url && relation.url.includes(`/${parentId}`), ); expect(parentRelation).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, AzureDevOpsValidationError, AzureDevOpsPermissionError, } from '../../../shared/errors'; import { createWiki } from './feature'; import { WikiType } from './schema'; import { getWikiClient } from '../../../clients/azure-devops'; // Mock the WikiClient jest.mock('../../../clients/azure-devops'); describe('createWiki unit', () => { // Mock WikiClient const mockWikiClient = { createWiki: jest.fn(), }; // Mock WebApi connection (kept for backward compatibility) const mockConnection = {} as WebApi; beforeEach(() => { // Clear mock calls between tests jest.clearAllMocks(); // Setup mock response for getWikiClient (getWikiClient as jest.Mock).mockResolvedValue(mockWikiClient); }); test('should create a project wiki', async () => { // Mock data const mockWiki = { id: 'wiki1', name: 'Project Wiki', projectId: 'project1', remoteUrl: 'https://example.com/wiki1', url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1', type: 'projectWiki', repositoryId: 'repo1', mappedPath: '/', }; // Setup mock response mockWikiClient.createWiki.mockResolvedValue(mockWiki); // Call the function const result = await createWiki(mockConnection, { name: 'Project Wiki', projectId: 'project1', }); // Assertions expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); expect(mockWikiClient.createWiki).toHaveBeenCalledWith('project1', { name: 'Project Wiki', projectId: 'project1', type: WikiType.ProjectWiki, }); expect(result).toEqual(mockWiki); }); test('should create a code wiki', async () => { // Mock data const mockWiki = { id: 'wiki2', name: 'Code Wiki', projectId: 'project1', repositoryId: 'repo1', mappedPath: '/docs', remoteUrl: 'https://example.com/wiki2', url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki2', type: 'codeWiki', }; // Setup mock response mockWikiClient.createWiki.mockResolvedValue(mockWiki); // Call the function const result = await createWiki(mockConnection, { name: 'Code Wiki', projectId: 'project1', type: WikiType.CodeWiki, repositoryId: 'repo1', mappedPath: '/docs', }); // Assertions expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); expect(mockWikiClient.createWiki).toHaveBeenCalledWith('project1', { name: 'Code Wiki', projectId: 'project1', type: WikiType.CodeWiki, repositoryId: 'repo1', mappedPath: '/docs', version: { version: 'main', versionType: 'branch' as const, }, }); expect(result).toEqual(mockWiki); }); test('should throw validation error when repository ID is missing for code wiki', async () => { // Call the function and expect it to throw await expect( createWiki(mockConnection, { name: 'Code Wiki', projectId: 'project1', type: WikiType.CodeWiki, // repositoryId is missing }), ).rejects.toThrow(AzureDevOpsValidationError); // Assertions expect(getWikiClient).not.toHaveBeenCalled(); expect(mockWikiClient.createWiki).not.toHaveBeenCalled(); }); test('should handle project not found error', async () => { // Setup mock to throw an error mockWikiClient.createWiki.mockRejectedValue( new AzureDevOpsResourceNotFoundError('Project not found'), ); // Call the function and expect it to throw await expect( createWiki(mockConnection, { name: 'Project Wiki', projectId: 'nonExistentProject', }), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); // Assertions expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); expect(mockWikiClient.createWiki).toHaveBeenCalled(); }); test('should handle repository not found error', async () => { // Setup mock to throw an error mockWikiClient.createWiki.mockRejectedValue( new AzureDevOpsResourceNotFoundError('Repository not found'), ); // Call the function and expect it to throw await expect( createWiki(mockConnection, { name: 'Code Wiki', projectId: 'project1', type: WikiType.CodeWiki, repositoryId: 'nonExistentRepo', }), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); // Assertions expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); expect(mockWikiClient.createWiki).toHaveBeenCalled(); }); test('should handle permission error', async () => { // Setup mock to throw an error mockWikiClient.createWiki.mockRejectedValue( new AzureDevOpsPermissionError('You do not have permission'), ); // Call the function and expect it to throw await expect( createWiki(mockConnection, { name: 'Project Wiki', projectId: 'project1', }), ).rejects.toThrow(AzureDevOpsPermissionError); // Assertions expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); expect(mockWikiClient.createWiki).toHaveBeenCalled(); }); test('should handle generic errors', async () => { // Setup mock to throw an error mockWikiClient.createWiki.mockRejectedValue(new Error('Unknown error')); // Call the function and expect it to throw await expect( createWiki(mockConnection, { name: 'Project Wiki', projectId: 'project1', }), ).rejects.toThrow(AzureDevOpsError); // Assertions expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); expect(mockWikiClient.createWiki).toHaveBeenCalled(); }); }); ``` -------------------------------------------------------------------------------- /src/features/search/search-wiki/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import axios from 'axios'; import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, AzureDevOpsValidationError, AzureDevOpsPermissionError, } from '../../../shared/errors'; import { SearchWikiOptions, WikiSearchRequest, WikiSearchResponse, } from '../types'; /** * Search for wiki pages in Azure DevOps projects * * @param connection The Azure DevOps WebApi connection * @param options Parameters for searching wiki pages * @returns Search results for wiki pages */ export async function searchWiki( connection: WebApi, options: SearchWikiOptions, ): Promise<WikiSearchResponse> { try { // Prepare the search request const searchRequest: WikiSearchRequest = { searchText: options.searchText, $skip: options.skip, $top: options.top, filters: options.projectId ? { Project: [options.projectId], } : {}, includeFacets: options.includeFacets, }; // Add custom filters if provided if ( options.filters && options.filters.Project && options.filters.Project.length > 0 ) { if (!searchRequest.filters) { searchRequest.filters = {}; } if (!searchRequest.filters.Project) { searchRequest.filters.Project = []; } searchRequest.filters.Project = [ ...(searchRequest.filters.Project || []), ...options.filters.Project, ]; } // Get the authorization header from the connection const authHeader = await getAuthorizationHeader(); // Extract organization and project from the connection URL const { organization, project } = extractOrgAndProject( connection, options.projectId, ); // Make the search API request // If projectId is provided, include it in the URL, otherwise perform organization-wide search const searchUrl = options.projectId ? `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/wikisearchresults?api-version=7.1` : `https://almsearch.dev.azure.com/${organization}/_apis/search/wikisearchresults?api-version=7.1`; const searchResponse = await axios.post<WikiSearchResponse>( searchUrl, searchRequest, { headers: { Authorization: authHeader, 'Content-Type': 'application/json', }, }, ); return searchResponse.data; } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Handle axios errors if (axios.isAxiosError(error)) { const status = error.response?.status; const message = error.response?.data?.message || error.message; if (status === 404) { throw new AzureDevOpsResourceNotFoundError( `Resource not found: ${message}`, ); } else if (status === 400) { throw new AzureDevOpsValidationError( `Invalid request: ${message}`, error.response?.data, ); } else if (status === 401 || status === 403) { throw new AzureDevOpsPermissionError(`Permission denied: ${message}`); } else { // For other axios errors, wrap in a generic AzureDevOpsError throw new AzureDevOpsError(`Azure DevOps API error: ${message}`); } // This return is never reached but helps TypeScript understand the control flow return null as never; } // Otherwise, wrap it in a generic error throw new AzureDevOpsError( `Failed to search wiki: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Extract organization and project from the connection URL * * @param connection The Azure DevOps WebApi connection * @param projectId The project ID or name (optional) * @returns The organization and project */ function extractOrgAndProject( connection: WebApi, projectId?: string, ): { organization: string; project: string } { // Extract organization from the connection URL const url = connection.serverUrl; const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); const organization = match ? match[1] : ''; if (!organization) { throw new AzureDevOpsValidationError( 'Could not extract organization from connection URL', ); } return { organization, project: projectId || '', }; } /** * Get the authorization header from the connection * * @returns The authorization header */ async function getAuthorizationHeader(): Promise<string> { try { // For PAT authentication, we can construct the header directly if ( process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && process.env.AZURE_DEVOPS_PAT ) { // For PAT auth, we can construct the Basic auth header directly const token = process.env.AZURE_DEVOPS_PAT; const base64Token = Buffer.from(`:${token}`).toString('base64'); return `Basic ${base64Token}`; } // For Azure Identity / Azure CLI auth, we need to get a token // using the Azure DevOps resource ID // Choose the appropriate credential based on auth method const credential = process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' ? new AzureCliCredential() : new DefaultAzureCredential(); // Azure DevOps resource ID for token acquisition const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; // Get token for Azure DevOps const token = await credential.getToken( `${AZURE_DEVOPS_RESOURCE_ID}/.default`, ); if (!token || !token.token) { throw new Error('Failed to acquire token for Azure DevOps'); } return `Bearer ${token.token}`; } catch (error) { throw new AzureDevOpsValidationError( `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/repositories/get-file-content/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { IGitApi } from 'azure-devops-node-api/GitApi'; import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; import { getFileContent } from './feature'; import { Readable } from 'stream'; describe('getFileContent', () => { let mockConnection: WebApi; let mockGitApi: IGitApi; const mockRepositoryId = 'test-repo'; const mockProjectId = 'test-project'; const mockFilePath = '/path/to/file.txt'; const mockFileContent = 'Test file content'; const mockItem = { objectId: '123456', path: mockFilePath, url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo/items/path/to/file.txt', gitObjectType: 'blob', }; // Helper function to create a readable stream from a string function createReadableStream(content: string): Readable { const stream = new Readable(); stream.push(content); stream.push(null); // Signals the end of the stream return stream; } beforeEach(() => { mockGitApi = { getItemContent: jest .fn() .mockResolvedValue(createReadableStream(mockFileContent)), getItems: jest.fn().mockResolvedValue([mockItem]), } as unknown as IGitApi; mockConnection = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), } as unknown as WebApi; }); it('should get file content for a file in the default branch', async () => { const result = await getFileContent( mockConnection, mockProjectId, mockRepositoryId, mockFilePath, ); expect(mockConnection.getGitApi).toHaveBeenCalled(); expect(mockGitApi.getItems).toHaveBeenCalledWith( mockRepositoryId, mockProjectId, mockFilePath, expect.any(Number), // VersionControlRecursionType.OneLevel undefined, undefined, undefined, undefined, undefined, ); expect(mockGitApi.getItemContent).toHaveBeenCalledWith( mockRepositoryId, mockFilePath, mockProjectId, undefined, undefined, undefined, undefined, false, undefined, true, ); expect(result).toEqual({ content: mockFileContent, isDirectory: false, }); }); it('should get file content for a file in a specific branch', async () => { const branchName = 'test-branch'; const versionDescriptor = { versionType: GitVersionType.Branch, version: branchName, versionOptions: undefined, }; const result = await getFileContent( mockConnection, mockProjectId, mockRepositoryId, mockFilePath, { versionType: GitVersionType.Branch, version: branchName, }, ); expect(mockConnection.getGitApi).toHaveBeenCalled(); expect(mockGitApi.getItems).toHaveBeenCalledWith( mockRepositoryId, mockProjectId, mockFilePath, expect.any(Number), // VersionControlRecursionType.OneLevel undefined, undefined, undefined, undefined, versionDescriptor, ); expect(mockGitApi.getItemContent).toHaveBeenCalledWith( mockRepositoryId, mockFilePath, mockProjectId, undefined, undefined, undefined, undefined, false, versionDescriptor, true, ); expect(result).toEqual({ content: mockFileContent, isDirectory: false, }); }); it('should throw an error if the file is not found', async () => { // Mock getItems to throw an error mockGitApi.getItems = jest .fn() .mockRejectedValue(new Error('Item not found')); // Mock getItemContent to throw a specific error indicating not found mockGitApi.getItemContent = jest .fn() .mockRejectedValue(new Error('Item not found')); await expect( getFileContent( mockConnection, mockProjectId, mockRepositoryId, '/invalid/path', ), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); }); it('should get directory content if the path is a directory', async () => { const dirPath = '/path/to/dir'; const mockDirectoryItems = [ { path: `${dirPath}/file1.txt`, gitObjectType: 'blob', isFolder: false, }, { path: `${dirPath}/file2.md`, gitObjectType: 'blob', isFolder: false, }, { path: `${dirPath}/subdir`, gitObjectType: 'tree', isFolder: true, }, ]; // Mock getItems to return multiple items, indicating a directory mockGitApi.getItems = jest.fn().mockResolvedValue(mockDirectoryItems); const result = await getFileContent( mockConnection, mockProjectId, mockRepositoryId, dirPath, ); expect(mockConnection.getGitApi).toHaveBeenCalled(); expect(mockGitApi.getItems).toHaveBeenCalledWith( mockRepositoryId, mockProjectId, dirPath, expect.any(Number), // VersionControlRecursionType.OneLevel undefined, undefined, undefined, undefined, undefined, ); // Should not attempt to get file content for a directory expect(mockGitApi.getItemContent).not.toHaveBeenCalled(); expect(result).toEqual({ content: JSON.stringify(mockDirectoryItems, null, 2), isDirectory: true, }); }); it('should handle a directory path with trailing slash', async () => { const dirPath = '/path/to/dir/'; const mockDirectoryItems = [ { path: `${dirPath}file1.txt`, gitObjectType: 'blob', isFolder: false, }, ]; // Even with one item, it should be treated as a directory due to trailing slash mockGitApi.getItems = jest.fn().mockResolvedValue(mockDirectoryItems); const result = await getFileContent( mockConnection, mockProjectId, mockRepositoryId, dirPath, ); expect(result.isDirectory).toBe(true); expect(result.content).toBe(JSON.stringify(mockDirectoryItems, null, 2)); }); }); ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { searchWorkItems } from './feature'; import { getConnection } from '../../../server'; import { AzureDevOpsConfig } from '../../../shared/types'; import { AuthenticationMethod } from '../../../shared/auth'; // Skip tests if no PAT is available const hasPat = process.env.AZURE_DEVOPS_PAT && process.env.AZURE_DEVOPS_ORG_URL; const describeOrSkip = hasPat ? describe : describe.skip; describeOrSkip('searchWorkItems (Integration)', () => { let connection: WebApi; let config: AzureDevOpsConfig; let projectId: string; beforeAll(async () => { // Set up the connection config = { organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', authMethod: AuthenticationMethod.PersonalAccessToken, personalAccessToken: process.env.AZURE_DEVOPS_PAT || '', defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT || '', }; connection = await getConnection(config); projectId = config.defaultProject || ''; // Skip tests if no default project is set if (!projectId) { console.warn('Skipping integration tests: No default project set'); } }, 30000); it('should search for work items', async () => { // Skip test if no default project if (!projectId) { return; } // Act const result = await searchWorkItems(connection, { searchText: 'test', projectId, top: 10, includeFacets: true, }); // Assert expect(result).toBeDefined(); expect(typeof result.count).toBe('number'); expect(Array.isArray(result.results)).toBe(true); // If there are results, verify their structure if (result.results.length > 0) { const firstResult = result.results[0]; expect(firstResult.project).toBeDefined(); expect(firstResult.fields).toBeDefined(); expect(firstResult.fields['system.id']).toBeDefined(); expect(firstResult.fields['system.title']).toBeDefined(); expect(firstResult.hits).toBeDefined(); expect(firstResult.url).toBeDefined(); } // If facets were requested, verify their structure if (result.facets) { expect(result.facets).toBeDefined(); } }, 30000); it('should filter work items by type', async () => { // Skip test if no default project if (!projectId) { return; } // Act const result = await searchWorkItems(connection, { searchText: 'test', projectId, filters: { 'System.WorkItemType': ['Bug'], }, top: 10, }); // Assert expect(result).toBeDefined(); // If there are results, verify they are all bugs if (result.results.length > 0) { result.results.forEach((item) => { expect(item.fields['system.workitemtype'].toLowerCase()).toBe('bug'); }); } }, 30000); it('should support pagination', async () => { // Skip test if no default project if (!projectId) { return; } // Act - Get first page const firstPage = await searchWorkItems(connection, { searchText: 'test', projectId, top: 5, skip: 0, }); // If there are enough results, test pagination if (firstPage.count > 5) { // Act - Get second page const secondPage = await searchWorkItems(connection, { searchText: 'test', projectId, top: 5, skip: 5, }); // Assert expect(secondPage).toBeDefined(); expect(secondPage.results).toBeDefined(); // Verify the pages have different items if (firstPage.results.length > 0 && secondPage.results.length > 0) { const firstPageIds = firstPage.results.map( (r) => r.fields['system.id'], ); const secondPageIds = secondPage.results.map( (r) => r.fields['system.id'], ); // Check that the pages don't have overlapping IDs const overlap = firstPageIds.filter((id) => secondPageIds.includes(id)); expect(overlap.length).toBe(0); } } }, 30000); it('should support sorting', async () => { // Skip test if no default project if (!projectId) { return; } // Act - Get results sorted by creation date (newest first) const result = await searchWorkItems(connection, { searchText: 'test', projectId, orderBy: [{ field: 'System.CreatedDate', sortOrder: 'DESC' }], top: 10, }); // Assert expect(result).toBeDefined(); // If there are multiple results, verify they are sorted if (result.results.length > 1) { const dates = result.results .filter((r) => r.fields['system.createddate'] !== undefined) .map((r) => new Date(r.fields['system.createddate'] as string).getTime(), ); // Check that dates are in descending order for (let i = 0; i < dates.length - 1; i++) { expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]); } } }, 30000); // Add a test to verify Azure Identity authentication if configured if ( process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-identity' ) { test('should search work items using Azure Identity authentication', async () => { // Skip if required environment variables are missing if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.TEST_PROJECT_ID) { console.log('Skipping test: required environment variables missing'); return; } // Create a config with Azure Identity authentication const testConfig: AzureDevOpsConfig = { organizationUrl: process.env.AZURE_DEVOPS_ORG_URL, authMethod: AuthenticationMethod.AzureIdentity, defaultProject: process.env.TEST_PROJECT_ID, }; // Create the connection using the config const connection = await getConnection(testConfig); // Search work items const result = await searchWorkItems(connection, { projectId: process.env.TEST_PROJECT_ID, searchText: 'test', }); // Check that the response is properly formatted expect(result).toBeDefined(); expect(result.count).toBeDefined(); expect(Array.isArray(result.results)).toBe(true); }); } }); ``` -------------------------------------------------------------------------------- /docs/tools/pipelines.md: -------------------------------------------------------------------------------- ```markdown # Pipeline Tools This document describes the tools available for working with Azure DevOps pipelines. ## Table of Contents - [`list_pipelines`](#list_pipelines) - List pipelines in a project - [`get_pipeline`](#get_pipeline) - Get details of a specific pipeline - [`trigger_pipeline`](#trigger_pipeline) - Trigger a pipeline run ## list_pipelines Lists pipelines in a project. ### Parameters | Parameter | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------- | | `projectId` | string | No | The ID or name of the project (Default: from environment) | | `orderBy` | string | No | Order by field and direction (e.g., "createdDate desc") | | `top` | number | No | Maximum number of pipelines to return | ### Response Returns an array of pipeline objects: ```json { "count": 2, "value": [ { "id": 4, "revision": 2, "name": "Node.js build pipeline", "folder": "\\", "url": "https://dev.azure.com/organization/project/_apis/pipelines/4" }, { "id": 1, "revision": 1, "name": "Sample Pipeline", "folder": "\\", "url": "https://dev.azure.com/organization/project/_apis/pipelines/1" } ] } ``` ### Error Handling - Returns `AzureDevOpsResourceNotFoundError` if the project does not exist - Returns `AzureDevOpsAuthenticationError` if authentication fails - Returns generic error messages for other failures ### Example Usage ```javascript // Using default project from environment const result = await callTool('list_pipelines', {}); // Specifying project and limiting results const limitedResult = await callTool('list_pipelines', { projectId: 'my-project', top: 10, orderBy: 'name asc', }); ``` ## get_pipeline Gets details of a specific pipeline. ### Parameters | Parameter | Type | Required | Description | | ----------------- | ------ | -------- | ----------------------------------------------------------------- | | `projectId` | string | No | The ID or name of the project (Default: from environment) | | `pipelineId` | number | Yes | The numeric ID of the pipeline to retrieve | | `pipelineVersion` | number | No | The version of the pipeline to retrieve (latest if not specified) | ### Response Returns a pipeline object with the following structure: ```json { "id": 4, "revision": 2, "name": "Node.js build pipeline", "folder": "\\", "url": "https://dev.azure.com/organization/project/_apis/pipelines/4", "_links": { "self": { "href": "https://dev.azure.com/organization/project/_apis/pipelines/4" }, "web": { "href": "https://dev.azure.com/organization/project/_build/definition?definitionId=4" } }, "configuration": { "path": "azure-pipelines.yml", "repository": { "id": "bd0e8130-7fba-4f3b-8559-54760b6e7248", "type": "azureReposGit" }, "type": "yaml" } } ``` ### Error Handling - Returns `AzureDevOpsResourceNotFoundError` if the pipeline or project does not exist - Returns `AzureDevOpsAuthenticationError` if authentication fails - Returns generic error messages for other failures ### Example Usage ```javascript // Get latest version of a pipeline const result = await callTool('get_pipeline', { pipelineId: 4, }); // Get specific version of a pipeline const versionResult = await callTool('get_pipeline', { projectId: 'my-project', pipelineId: 4, pipelineVersion: 2, }); ``` ## trigger_pipeline Triggers a run of a specific pipeline. Allows specifying the branch to run on and passing variables to customize the pipeline execution. ### Parameters | Parameter | Type | Required | Description | | -------------------- | ------ | -------- | --------------------------------------------------------------------- | | `projectId` | string | No | The ID or name of the project (Default: from environment) | | `pipelineId` | number | Yes | The numeric ID of the pipeline to trigger | | `branch` | string | No | The branch to run the pipeline on (e.g., "main", "feature/my-branch") | | `variables` | object | No | Variables to pass to the pipeline run | | `templateParameters` | object | No | Parameters for template-based pipelines | | `stagesToSkip` | array | No | Stages to skip in the pipeline run | #### Variables Format ```json { "myVariable": { "value": "my-value", "isSecret": false }, "secretVariable": { "value": "secret-value", "isSecret": true } } ``` ### Response Returns a run object with details about the triggered pipeline run: ```json { "id": 12345, "name": "20230215.1", "createdDate": "2023-02-15T10:30:00Z", "url": "https://dev.azure.com/organization/project/_apis/pipelines/runs/12345", "_links": { "self": { "href": "https://dev.azure.com/organization/project/_apis/pipelines/runs/12345" }, "web": { "href": "https://dev.azure.com/organization/project/_build/results?buildId=12345" } }, "state": 1, "result": null, "variables": { "myVariable": { "value": "my-value" } } } ``` ### Error Handling - Returns `AzureDevOpsResourceNotFoundError` if the pipeline or project does not exist - Returns `AzureDevOpsAuthenticationError` if authentication fails - Returns generic error messages for other failures ### Example Usage ```javascript // Trigger a pipeline on the default branch // In this case, use default project from environment variables const result = await callTool('trigger_pipeline', { pipelineId: 4, }); // Trigger a pipeline on a specific branch with variables const runWithOptions = await callTool('trigger_pipeline', { projectId: 'my-project', pipelineId: 4, branch: 'feature/my-branch', variables: { deployEnvironment: { value: 'staging', isSecret: false, }, }, }); ``` ``` -------------------------------------------------------------------------------- /docs/tools/work-items.md: -------------------------------------------------------------------------------- ```markdown # Work Item Tools This document describes the tools available for working with Azure DevOps work items. ## Table of Contents - [`get_work_item`](#get_work_item) - Retrieve a specific work item by ID - [`create_work_item`](#create_work_item) - Create a new work item - [`list_work_items`](#list_work_items) - List work items in a project ## get_work_item Retrieves a work item by its ID. ### Parameters | Parameter | Type | Required | Description | | ------------ | ------ | -------- | --------------------------------------------------------------------------------- | | `workItemId` | number | Yes | The ID of the work item to retrieve | | `expand` | string | No | Controls the level of detail in the response. Defaults to "All" if not specified. Other values: "Relations", "Fields", "None" | ### Response Returns a work item object with the following structure: ```json { "id": 123, "fields": { "System.Title": "Sample Work Item", "System.State": "Active", "System.AssignedTo": "[email protected]", "System.Description": "Description of the work item" }, "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/123" } ``` ### Error Handling - Returns `AzureDevOpsResourceNotFoundError` if the work item does not exist - Returns `AzureDevOpsAuthenticationError` if authentication fails - Returns generic error messages for other failures ### Example Usage ```javascript // Using default expand="All" const result = await callTool('get_work_item', { workItemId: 123, }); // Explicitly specifying expand const minimalResult = await callTool('get_work_item', { workItemId: 123, expand: 'None' }); ``` ## create_work_item Creates a new work item in a specified project. ### Parameters | Parameter | Type | Required | Description | | ------------------ | ------ | -------- | ------------------------------------------------------------------- | | `projectId` | string | Yes | The ID or name of the project where the work item will be created | | `workItemType` | string | Yes | The type of work item to create (e.g., "Task", "Bug", "User Story") | | `title` | string | Yes | The title of the work item | | `description` | string | No | The description of the work item | | `assignedTo` | string | No | The email or name of the user to assign the work item to | | `areaPath` | string | No | The area path for the work item | | `iterationPath` | string | No | The iteration path for the work item | | `priority` | number | No | The priority of the work item | | `additionalFields` | object | No | Additional fields to set on the work item (key-value pairs) | ### Response Returns the newly created work item object: ```json { "id": 124, "fields": { "System.Title": "New Work Item", "System.State": "New", "System.Description": "Description of the new work item", "System.AssignedTo": "[email protected]", "System.AreaPath": "Project\\Team", "System.IterationPath": "Project\\Sprint 1", "Microsoft.VSTS.Common.Priority": 2 }, "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/124" } ``` ### Error Handling - Returns validation error if required fields are missing - Returns `AzureDevOpsAuthenticationError` if authentication fails - Returns `AzureDevOpsResourceNotFoundError` if the project does not exist - Returns generic error messages for other failures ### Example Usage ```javascript const result = await callTool('create_work_item', { projectId: 'my-project', workItemType: 'User Story', title: 'Implement login functionality', description: 'Create a secure login system with email and password authentication', assignedTo: '[email protected]', priority: 1, additionalFields: { 'Custom.Field': 'Custom Value', }, }); ``` ### Implementation Details The tool creates a JSON patch document to define the fields of the work item, then calls the Azure DevOps API to create the work item. Each field is added to the document with an 'add' operation, and the document is submitted to the API. ## list_work_items Lists work items in a specified project. ### Parameters | Parameter | Type | Required | Description | | ----------- | ------ | -------- | ----------------------------------------------------- | | `projectId` | string | Yes | The ID or name of the project to list work items from | | `teamId` | string | No | The ID of the team to list work items for | | `queryId` | string | No | ID of a saved work item query | | `wiql` | string | No | Work Item Query Language (WIQL) query | | `top` | number | No | Maximum number of work items to return | | `skip` | number | No | Number of work items to skip | ### Response Returns an array of work item objects: ```json [ { "id": 123, "fields": { "System.Title": "Sample Work Item", "System.State": "Active", "System.AssignedTo": "[email protected]" }, "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/123" }, { "id": 124, "fields": { "System.Title": "Another Work Item", "System.State": "New", "System.AssignedTo": "[email protected]" }, "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/124" } ] ``` ### Error Handling - Returns `AzureDevOpsResourceNotFoundError` if the project does not exist - Returns `AzureDevOpsAuthenticationError` if authentication fails - Returns generic error messages for other failures ### Example Usage ```javascript const result = await callTool('list_work_items', { projectId: 'my-project', wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Task' ORDER BY [System.CreatedDate] DESC", top: 10, }); ``` ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { createWikiPage } from './feature'; import { handleRequestError } from '../../../shared/errors/handle-request-error'; // Mock the AzureDevOpsClient jest.mock('../../../shared/api/client'); // Mock the error handler jest.mock('../../../shared/errors/handle-request-error', () => ({ handleRequestError: jest.fn(), })); describe('createWikiPage Feature', () => { let client: any; const mockPut = jest.fn(); const mockHandleRequestError = handleRequestError as jest.MockedFunction< typeof handleRequestError >; const defaultParams = { wikiId: 'test-wiki', content: 'Hello world', pagePath: '/', }; beforeEach(() => { // Reset mocks for each test mockPut.mockReset(); mockHandleRequestError.mockReset(); client = { put: mockPut, defaults: { organizationId: 'defaultOrg', projectId: 'defaultProject', }, }; }); it('should call client.put with correct URL and data for default org and project', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); await createWikiPage(defaultParams, client as any); expect(mockPut).toHaveBeenCalledTimes(1); expect(mockPut).toHaveBeenCalledWith( 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', { content: 'Hello world' }, ); }); it('should call client.put with correct URL when projectId is explicitly provided', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); const paramsWithProject = { ...defaultParams, projectId: 'customProject', }; await createWikiPage(paramsWithProject, client as any); expect(mockPut).toHaveBeenCalledWith( 'defaultOrg/customProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', { content: 'Hello world' }, ); }); it('should call client.put with correct URL when organizationId is explicitly provided', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); const paramsWithOrg = { ...defaultParams, organizationId: 'customOrg', }; await createWikiPage(paramsWithOrg, client as any); expect(mockPut).toHaveBeenCalledWith( 'customOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', { content: 'Hello world' }, ); }); it('should call client.put with correct URL when projectId is null (project-level wiki)', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); const paramsWithNullProject = { ...defaultParams, projectId: null, // Explicitly null for project-level resources that don't need a project }; // Client default for projectId should also be null or undefined in this scenario const clientWithoutProject = { put: mockPut, defaults: { organizationId: 'defaultOrg', projectId: undefined, }, }; await createWikiPage(paramsWithNullProject, clientWithoutProject as any); expect(mockPut).toHaveBeenCalledWith( 'defaultOrg/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', { content: 'Hello world' }, ); }); it('should correctly encode pagePath in the URL', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); const paramsWithPath = { ...defaultParams, pagePath: '/My Test Page/Sub Page', }; await createWikiPage(paramsWithPath, client as any); expect(mockPut).toHaveBeenCalledWith( 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2FMy%20Test%20Page%2FSub%20Page&api-version=7.1-preview.1', { content: 'Hello world' }, ); }); it('should use default pagePath "/" if pagePath is null', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); const paramsWithPath = { ...defaultParams, pagePath: null, // Explicitly null }; await createWikiPage(paramsWithPath, client as any); expect(mockPut).toHaveBeenCalledWith( 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', { content: 'Hello world' }, ); }); it('should include comment in request body when provided', async () => { mockPut.mockResolvedValue({ data: { some: 'response' } }); const paramsWithComment = { ...defaultParams, comment: 'Initial page creation', }; await createWikiPage(paramsWithComment, client as any); expect(mockPut).toHaveBeenCalledWith( 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', { content: 'Hello world', comment: 'Initial page creation' }, ); }); it('should return the data from the response on success', async () => { const expectedResponse = { id: '123', path: '/', content: 'Hello world' }; mockPut.mockResolvedValue({ data: expectedResponse }); const result = await createWikiPage(defaultParams, client as any); expect(result).toEqual(expectedResponse); }); // Skip this test for now as it requires complex mocking of environment variables it.skip('should throw if organizationId is not provided and not set in defaults', async () => { const clientWithoutOrg = { put: mockPut, defaults: { projectId: 'defaultProject', organizationId: undefined, }, }; const paramsNoOrg = { ...defaultParams, organizationId: null, // Explicitly null and no default }; // This test is skipped because it requires complex mocking of environment variables // which is difficult to do in the current test setup await expect( createWikiPage(paramsNoOrg, clientWithoutOrg as any), ).rejects.toThrow( 'Organization ID is not defined. Please provide it or set a default.', ); expect(mockPut).not.toHaveBeenCalled(); }); it('should call handleRequestError if client.put throws an error', async () => { const error = new Error('API Error'); mockPut.mockRejectedValue(error); mockHandleRequestError.mockImplementation(() => { throw new Error('Handled Error'); }); await expect(createWikiPage(defaultParams, client as any)).rejects.toThrow( 'Handled Error', ); expect(mockHandleRequestError).toHaveBeenCalledTimes(1); expect(mockHandleRequestError).toHaveBeenCalledWith( error, 'Failed to create or update wiki page', ); }); }); ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getProjectDetails } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getProjectDetails 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 basic project details 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 getProjectDetails(connection, { projectId: 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(); expect(result.capabilities).toBeDefined(); }); test('should retrieve project details with teams 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 getProjectDetails(connection, { projectId: projectName, includeTeams: true, }); // Assert on the actual response expect(result).toBeDefined(); expect(result.teams).toBeDefined(); expect(Array.isArray(result.teams)).toBe(true); // There should be at least one team (the default team) if (result.teams && result.teams.length > 0) { const team = result.teams[0]; expect(team.id).toBeDefined(); expect(team.name).toBeDefined(); expect(team.url).toBeDefined(); } }); test('should retrieve project details with process information 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 getProjectDetails(connection, { projectId: projectName, includeProcess: true, }); // Assert on the actual response expect(result).toBeDefined(); expect(result.process).toBeDefined(); expect(result.process?.name).toBeDefined(); }); test('should retrieve project details with work item types 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 getProjectDetails(connection, { projectId: projectName, includeProcess: true, includeWorkItemTypes: true, }); // Assert on the actual response expect(result).toBeDefined(); expect(result.process).toBeDefined(); expect(result.process?.workItemTypes).toBeDefined(); expect(Array.isArray(result.process?.workItemTypes)).toBe(true); // There should be at least one work item type if ( result.process?.workItemTypes && result.process.workItemTypes.length > 0 ) { const workItemType = result.process.workItemTypes[0]; expect(workItemType.name).toBeDefined(); expect(workItemType.description).toBeDefined(); expect(workItemType.states).toBeDefined(); } }); test('should retrieve project details with fields 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 getProjectDetails(connection, { projectId: projectName, includeProcess: true, includeWorkItemTypes: true, includeFields: true, }); // Assert on the actual response expect(result).toBeDefined(); expect(result.process).toBeDefined(); expect(result.process?.workItemTypes).toBeDefined(); // There should be at least one work item type with fields if ( result.process?.workItemTypes && result.process.workItemTypes.length > 0 ) { const workItemType = result.process.workItemTypes[0]; expect(workItemType.fields).toBeDefined(); expect(Array.isArray(workItemType.fields)).toBe(true); // There should be at least one field (like Title) if (workItemType.fields && workItemType.fields.length > 0) { const field = workItemType.fields[0]; expect(field.name).toBeDefined(); expect(field.referenceName).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( getProjectDetails(connection, { projectId: nonExistentProjectName, }), ).rejects.toThrow(/not found|Failed to get project/); }); }); ``` -------------------------------------------------------------------------------- /src/features/pull-requests/schemas.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultProject, defaultOrg } from '../../utils/environment'; /** * Schema for creating a pull request */ export const CreatePullRequestSchema = 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})`), repositoryId: z.string().describe('The ID or name of the repository'), title: z.string().describe('The title of the pull request'), description: z .string() .optional() .describe('The description of the pull request (markdown is supported)'), sourceRefName: z .string() .describe('The source branch name (e.g., refs/heads/feature-branch)'), targetRefName: z .string() .describe('The target branch name (e.g., refs/heads/main)'), reviewers: z .array(z.string()) .optional() .describe('List of reviewer email addresses or IDs'), isDraft: z .boolean() .optional() .describe('Whether the pull request should be created as a draft'), workItemRefs: z .array(z.number()) .optional() .describe('List of work item IDs to link to the pull request'), additionalProperties: z .record(z.string(), z.any()) .optional() .describe('Additional properties to set on the pull request'), }); /** * Schema for listing pull requests */ export const ListPullRequestsSchema = 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})`), repositoryId: z.string().describe('The ID or name of the repository'), status: z .enum(['all', 'active', 'completed', 'abandoned']) .optional() .describe('Filter by pull request status'), creatorId: z .string() .optional() .describe('Filter by creator ID (must be a UUID string)'), reviewerId: z .string() .optional() .describe('Filter by reviewer ID (must be a UUID string)'), sourceRefName: z.string().optional().describe('Filter by source branch name'), targetRefName: z.string().optional().describe('Filter by target branch name'), top: z .number() .default(10) .describe('Maximum number of pull requests to return (default: 10)'), skip: z .number() .optional() .describe('Number of pull requests to skip for pagination'), }); /** * Schema for getting pull request comments */ export const GetPullRequestCommentsSchema = 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})`), repositoryId: z.string().describe('The ID or name of the repository'), pullRequestId: z.number().describe('The ID of the pull request'), threadId: z .number() .optional() .describe('The ID of the specific thread to get comments from'), includeDeleted: z .boolean() .optional() .describe('Whether to include deleted comments'), top: z .number() .optional() .describe('Maximum number of threads/comments to return'), }); /** * Schema for adding a comment to a pull request */ export const AddPullRequestCommentSchema = 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})`), repositoryId: z.string().describe('The ID or name of the repository'), pullRequestId: z.number().describe('The ID of the pull request'), content: z.string().describe('The content of the comment in markdown'), threadId: z .number() .optional() .describe('The ID of the thread to add the comment to'), parentCommentId: z .number() .optional() .describe( 'ID of the parent comment when replying to an existing comment', ), filePath: z .string() .optional() .describe('The path of the file to comment on (for new thread on file)'), lineNumber: z .number() .optional() .describe('The line number to comment on (for new thread on file)'), status: z .enum([ 'active', 'fixed', 'wontFix', 'closed', 'pending', 'byDesign', 'unknown', ]) .optional() .describe('The status to set for a new thread'), }) .superRefine((data, ctx) => { // If we're creating a new thread (no threadId), status is required if (!data.threadId && !data.status) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Status is required when creating a new thread', path: ['status'], }); } }); /** * Schema for updating a pull request */ export const UpdatePullRequestSchema = 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})`), repositoryId: z.string().describe('The ID or name of the repository'), pullRequestId: z.number().describe('The ID of the pull request to update'), title: z .string() .optional() .describe('The updated title of the pull request'), description: z .string() .optional() .describe('The updated description of the pull request'), status: z .enum(['active', 'abandoned', 'completed']) .optional() .describe('The updated status of the pull request'), isDraft: z .boolean() .optional() .describe( 'Whether the pull request should be marked as a draft (true) or unmarked (false)', ), addWorkItemIds: z .array(z.number()) .optional() .describe('List of work item IDs to link to the pull request'), removeWorkItemIds: z .array(z.number()) .optional() .describe('List of work item IDs to unlink from the pull request'), addReviewers: z .array(z.string()) .optional() .describe('List of reviewer email addresses or IDs to add'), removeReviewers: z .array(z.string()) .optional() .describe('List of reviewer email addresses or IDs to remove'), additionalProperties: z .record(z.string(), z.any()) .optional() .describe('Additional properties to update on the pull request'), }); ``` -------------------------------------------------------------------------------- /project-management/planning/the-dream-team.md: -------------------------------------------------------------------------------- ```markdown Below is the **Dream Team Documentation** for building the Azure DevOps MCP server. This document outlines the ideal roles and skill sets required to ensure the project's success, from development to deployment. Each role is carefully selected to address the technical, security, and operational challenges of building a robust, AI-integrated server. --- ## Dream Team Documentation: Building the Azure DevOps MCP Server ### Overview The Azure DevOps MCP server is a complex tool that requires a multidisciplinary team with expertise in software development, Azure DevOps, security, testing, documentation, project management, and AI integration. The following roles are essential to ensure the server is built efficiently, securely, and in alignment with the Model Context Protocol (MCP) standards. ### Key Roles and Responsibilities #### 1. **Full-Stack Developer (Typescript/Node.js)** - **Responsibilities**: - Implement the server's core functionality using Typescript and Node.js. - Develop and maintain MCP tools (e.g., `list_projects`, `create_work_item`). - Write tests as part of the implementation process (TDD). - Integrate with the MCP Typescript SDK and Azure DevOps APIs. - Write clean, modular, and efficient code following best practices. - Ensure code quality through comprehensive unit and integration tests. - Build automated testing pipelines for continuous integration. - Perform integration testing across components. - **Required Skills**: - Proficiency in Typescript and Node.js. - Strong testing skills and experience with test frameworks (e.g., Jest). - Experience writing testable code and following TDD practices. - Experience with REST APIs and asynchronous programming. - Familiarity with Git and version control systems. - Understanding of modular software design. - Experience with API testing and mocking tools. #### 2. **Azure DevOps API Expert** - **Responsibilities**: - Guide the team on effectively using Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). - Ensure the server leverages Azure DevOps features optimally (e.g., repository operations, pipelines). - Assist in mapping MCP tools to the correct API endpoints. - Troubleshoot API-related issues and optimize API usage. - Help develop tests for Azure DevOps API integrations. - **Required Skills**: - Deep understanding of Azure DevOps services and their REST APIs. - Experience with Azure DevOps workflows (e.g., repositories, work items, pipelines). - Knowledge of Azure DevOps authentication mechanisms (PAT, AAD). - Ability to interpret API documentation and handle rate limits. - Experience testing API integrations. #### 3. **Security Specialist** - **Responsibilities**: - Design and implement secure authentication methods (PAT and AAD). - Ensure credentials are stored and managed securely (e.g., environment variables). - Scope permissions to the minimum required for each tool. - Implement error handling and logging without exposing sensitive data. - Conduct security reviews and recommend improvements. - Develop security tests and validation procedures. - **Required Skills**: - Expertise in API security, authentication, and authorization. - Familiarity with Azure Active Directory and PAT management. - Knowledge of secure coding practices and vulnerability prevention. - Experience with logging, auditing, and compliance. - Experience with security testing tools and methodologies. #### 4. **Technical Writer** - **Responsibilities**: - Create comprehensive documentation, including setup guides, tool descriptions, and usage examples. - Write clear API references and troubleshooting tips. - Ensure documentation is accessible to both technical and non-technical users. - Maintain up-to-date documentation as the server evolves. - **Required Skills**: - Strong technical writing and communication skills. - Ability to explain complex concepts simply. - Experience documenting APIs and developer tools. - Familiarity with Markdown and documentation platforms (e.g., GitHub README). #### 5. **Project Manager** - **Responsibilities**: - Coordinate the team's efforts and manage the project timeline. - Track progress using Azure Boards or similar tools. - Facilitate communication and resolve blockers. - Ensure the project stays on scope and meets deadlines. - Manage stakeholder expectations and provide status updates. - **Required Skills**: - Experience in agile project management. - Proficiency with project tracking tools (e.g., Azure Boards, Jira). - Strong organizational and leadership skills. - Ability to manage remote or distributed teams. #### 6. **AI Integration Consultant** - **Responsibilities**: - Advise on how the server can best integrate with AI models (e.g., Claude Desktop). - Ensure tools are designed to support AI-driven workflows (e.g., user story to pull request). - Provide insights into MCP's AI integration capabilities. - Assist in testing AI interactions with the server. - **Required Skills**: - Experience with AI model integration and workflows. - Understanding of the Model Context Protocol (MCP). - Familiarity with AI tools like Claude Desktop. - Ability to bridge AI and software development domains. --- ### Team Structure and Collaboration - **Core Team**: Full-Stack Developer, Azure DevOps API Expert, Security Specialist. - **Support Roles**: Technical Writer, Project Manager, AI Integration Consultant. - **Collaboration**: Use Agile methodologies with bi-weekly sprints, daily stand-ups, and regular retrospectives to iterate efficiently. - **Communication Tools**: Slack or Microsoft Teams for real-time communication, Azure Boards for task tracking, and GitHub/Azure DevOps for version control and code reviews. --- ### Why This Team? Each role addresses a critical aspect of the project: - The **Full-Stack Developer** builds the server using modern technologies like Typescript and Node.js, integrating testing throughout the development process. - The **Azure DevOps API Expert** ensures seamless integration with Azure DevOps services. - The **Security Specialist** safeguards the server against vulnerabilities. - The **Technical Writer** makes the server user-friendly with clear documentation. - The **Project Manager** keeps the team aligned and on schedule. - The **AI Integration Consultant** ensures the server meets AI-driven workflow requirements. This dream team combines technical expertise, security, integrated quality assurance, and project management to deliver a high-quality, secure, and user-friendly Azure DevOps MCP server. Testing is built into our development process, not treated as a separate concern. ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getRepositoryDetails } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getRepositoryDetails 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 repository details 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 getRepositoryDetails(connection, { projectId: projectName, repositoryId: testRepo.name || testRepo.id || '', }); // Assert on the actual response expect(result).toBeDefined(); expect(result.repository).toBeDefined(); expect(result.repository.id).toBe(testRepo.id); expect(result.repository.name).toBe(testRepo.name); expect(result.repository.project).toBeDefined(); if (result.repository.project) { expect(result.repository.project.name).toBe(projectName); } }); test('should retrieve repository details with statistics', 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 getRepositoryDetails(connection, { projectId: projectName, repositoryId: testRepo.name || testRepo.id || '', includeStatistics: true, }); // Assert on the actual response expect(result).toBeDefined(); expect(result.repository).toBeDefined(); expect(result.repository.id).toBe(testRepo.id); expect(result.statistics).toBeDefined(); expect(Array.isArray(result.statistics?.branches)).toBe(true); }); test('should retrieve repository details with refs', 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 getRepositoryDetails(connection, { projectId: projectName, repositoryId: testRepo.name || testRepo.id || '', includeRefs: true, }); // Assert on the actual response expect(result).toBeDefined(); expect(result.repository).toBeDefined(); expect(result.repository.id).toBe(testRepo.id); expect(result.refs).toBeDefined(); expect(result.refs?.value).toBeDefined(); expect(Array.isArray(result.refs?.value)).toBe(true); expect(typeof result.refs?.count).toBe('number'); }); test('should retrieve repository details with refs filtered by heads/', 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 getRepositoryDetails(connection, { projectId: projectName, repositoryId: testRepo.name || testRepo.id || '', includeRefs: true, refFilter: 'heads/', }); // Assert on the actual response expect(result).toBeDefined(); expect(result.repository).toBeDefined(); expect(result.refs).toBeDefined(); expect(result.refs?.value).toBeDefined(); // All refs should start with refs/heads/ if (result.refs && result.refs.value.length > 0) { result.refs.value.forEach((ref) => { expect(ref.name).toMatch(/^refs\/heads\//); }); } }); 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( getRepositoryDetails(connection, { projectId: projectName, repositoryId: nonExistentRepoName, }), ).rejects.toThrow(/not found|Failed to get repository/); }); }); ``` -------------------------------------------------------------------------------- /src/features/pull-requests/list-pull-requests/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { listPullRequests } from './feature'; import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; describe('listPullRequests', () => { afterEach(() => { jest.resetAllMocks(); }); test('should return pull requests successfully with pagination metadata', async () => { // Mock data const mockPullRequests = [ { pullRequestId: 1, title: 'Test PR 1', description: 'Test PR description 1', }, { pullRequestId: 2, title: 'Test PR 2', description: 'Test PR description 2', }, ]; // Setup mock connection const mockGitApi = { getPullRequests: jest.fn().mockResolvedValue(mockPullRequests), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const options = { projectId, repositoryId, status: 'active' as const, top: 10, }; const result = await listPullRequests( mockConnection as WebApi, projectId, repositoryId, options, ); // Verify results expect(result).toEqual({ count: 2, value: mockPullRequests, hasMoreResults: false, warning: undefined, }); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.getPullRequests).toHaveBeenCalledTimes(1); expect(mockGitApi.getPullRequests).toHaveBeenCalledWith( repositoryId, { status: PullRequestStatus.Active }, projectId, undefined, // maxCommentLength 0, // skip 10, // top ); }); test('should return empty array when no pull requests exist', async () => { // Setup mock connection const mockGitApi = { getPullRequests: jest.fn().mockResolvedValue(null), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const options = { projectId, repositoryId }; const result = await listPullRequests( mockConnection as WebApi, projectId, repositoryId, options, ); // Verify results expect(result).toEqual({ count: 0, value: [], hasMoreResults: false, warning: undefined, }); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.getPullRequests).toHaveBeenCalledTimes(1); }); test('should handle all filter options correctly', async () => { // Setup mock connection const mockGitApi = { getPullRequests: jest.fn().mockResolvedValue([]), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call with all options const projectId = 'test-project'; const repositoryId = 'test-repo'; const options = { projectId, repositoryId, status: 'completed' as const, creatorId: 'a8a8a8a8-a8a8-a8a8-a8a8-a8a8a8a8a8a8', reviewerId: 'b9b9b9b9-b9b9-b9b9-b9b9-b9b9b9b9b9b9', sourceRefName: 'refs/heads/source-branch', targetRefName: 'refs/heads/target-branch', top: 5, skip: 10, }; await listPullRequests( mockConnection as WebApi, projectId, repositoryId, options, ); // Verify the search criteria was constructed correctly expect(mockGitApi.getPullRequests).toHaveBeenCalledWith( repositoryId, { status: PullRequestStatus.Completed, creatorId: 'a8a8a8a8-a8a8-a8a8-a8a8-a8a8a8a8a8a8', reviewerId: 'b9b9b9b9-b9b9-b9b9-b9b9-b9b9b9b9b9b9', sourceRefName: 'refs/heads/source-branch', targetRefName: 'refs/heads/target-branch', }, projectId, undefined, // maxCommentLength 10, // skip 5, // top ); }); test('should throw error when API call fails', async () => { // Setup mock connection const errorMessage = 'API error'; const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getPullRequests: jest.fn().mockRejectedValue(new Error(errorMessage)), })), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const options = { projectId, repositoryId }; // Verify error handling await expect( listPullRequests( mockConnection as WebApi, projectId, repositoryId, options, ), ).rejects.toThrow(`Failed to list pull requests: ${errorMessage}`); }); test('should use default pagination values when not provided', async () => { // Mock data const mockPullRequests = [ { pullRequestId: 1, title: 'Test PR 1' }, { pullRequestId: 2, title: 'Test PR 2' }, ]; // Setup mock connection const mockGitApi = { getPullRequests: jest.fn().mockResolvedValue(mockPullRequests), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with minimal parameters (no top or skip) const projectId = 'test-project'; const repositoryId = 'test-repo'; const options = { projectId, repositoryId }; const result = await listPullRequests( mockConnection as WebApi, projectId, repositoryId, options, ); // Verify default values were used expect(mockGitApi.getPullRequests).toHaveBeenCalledWith( repositoryId, {}, projectId, undefined, // maxCommentLength 0, // default skip 10, // default top ); expect(result.count).toBe(2); expect(result.value).toEqual(mockPullRequests); }); test('should add warning when hasMoreResults is true', async () => { // Create exactly 10 mock pull requests to trigger hasMoreResults const mockPullRequests = Array(10) .fill(0) .map((_, i) => ({ pullRequestId: i + 1, title: `Test PR ${i + 1}`, })); // Setup mock connection const mockGitApi = { getPullRequests: jest.fn().mockResolvedValue(mockPullRequests), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call with top=10 to match the number of results const projectId = 'test-project'; const repositoryId = 'test-repo'; const options = { projectId, repositoryId, top: 10, skip: 5, }; const result = await listPullRequests( mockConnection as WebApi, projectId, repositoryId, options, ); // Verify hasMoreResults is true and warning is set expect(result.hasMoreResults).toBe(true); expect(result.warning).toBe( "Results limited to 10 items. Use 'skip: 15' to get the next page.", ); }); }); ``` -------------------------------------------------------------------------------- /src/features/pull-requests/update-pull-request/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { updatePullRequest } from './feature'; import { AzureDevOpsClient } from '../../../shared/auth/client-factory'; import { AzureDevOpsError } from '../../../shared/errors'; // Mock the AzureDevOpsClient jest.mock('../../../shared/auth/client-factory'); describe('updatePullRequest', () => { const mockGetPullRequestById = jest.fn(); const mockUpdatePullRequest = jest.fn(); const mockUpdateWorkItem = jest.fn(); const mockGetWorkItem = jest.fn(); // Mock Git API const mockGitApi = { getPullRequestById: mockGetPullRequestById, updatePullRequest: mockUpdatePullRequest, }; // Mock Work Item Tracking API const mockWorkItemTrackingApi = { updateWorkItem: mockUpdateWorkItem, getWorkItem: mockGetWorkItem, }; // Mock connection const mockConnection = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), getWorkItemTrackingApi: jest .fn() .mockResolvedValue(mockWorkItemTrackingApi), }; const mockAzureDevopsClient = { getWebApiClient: jest.fn().mockResolvedValue(mockConnection), // ...other properties if needed }; beforeEach(() => { jest.clearAllMocks(); (AzureDevOpsClient as unknown as jest.Mock).mockImplementation( () => mockAzureDevopsClient, ); }); it('should throw error when pull request does not exist', async () => { mockGetPullRequestById.mockResolvedValueOnce(null); await expect( updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, }), ).rejects.toThrow(AzureDevOpsError); }); it('should update the pull request title and description', async () => { mockGetPullRequestById.mockResolvedValueOnce({ repository: { id: 'repo1' }, }); mockUpdatePullRequest.mockResolvedValueOnce({ title: 'Updated Title', description: 'Updated Description', }); const result = await updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, title: 'Updated Title', description: 'Updated Description', }); expect(mockUpdatePullRequest).toHaveBeenCalledWith( { title: 'Updated Title', description: 'Updated Description', }, 'repo1', 123, 'project-1', ); expect(result).toEqual({ title: 'Updated Title', description: 'Updated Description', }); }); it('should update the pull request status when status is provided', async () => { mockGetPullRequestById.mockResolvedValueOnce({ repository: { id: 'repo1' }, }); mockUpdatePullRequest.mockResolvedValueOnce({ status: 2, // Abandoned }); const result = await updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, status: 'abandoned', }); expect(mockUpdatePullRequest).toHaveBeenCalledWith( { status: 2, // Abandoned value }, 'repo1', 123, 'project-1', ); expect(result).toEqual({ status: 2, // Abandoned }); }); it('should throw error for invalid status', async () => { mockGetPullRequestById.mockResolvedValueOnce({ repository: { id: 'repo1' }, }); await expect( updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, status: 'invalid-status' as any, }), ).rejects.toThrow(AzureDevOpsError); }); it('should update the pull request draft status', async () => { mockGetPullRequestById.mockResolvedValueOnce({ repository: { id: 'repo1' }, }); mockUpdatePullRequest.mockResolvedValueOnce({ isDraft: true, }); const result = await updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, isDraft: true, }); expect(mockUpdatePullRequest).toHaveBeenCalledWith( { isDraft: true, }, 'repo1', 123, 'project-1', ); expect(result).toEqual({ isDraft: true, }); }); it('should include additionalProperties in the update', async () => { mockGetPullRequestById.mockResolvedValueOnce({ repository: { id: 'repo1' }, }); mockUpdatePullRequest.mockResolvedValueOnce({ title: 'Title', customProperty: 'custom value', }); const result = await updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, additionalProperties: { customProperty: 'custom value', }, }); expect(mockUpdatePullRequest).toHaveBeenCalledWith( { customProperty: 'custom value', }, 'repo1', 123, 'project-1', ); expect(result).toEqual({ title: 'Title', customProperty: 'custom value', }); }); it('should handle work item links', async () => { // Define the artifactId that will be used const artifactId = 'vstfs:///Git/PullRequestId/project-1/repo1/123'; mockGetPullRequestById.mockResolvedValueOnce({ repository: { id: 'repo1' }, artifactId: artifactId, // Add the artifactId to the mock response }); mockUpdatePullRequest.mockResolvedValueOnce({ pullRequestId: 123, repository: { id: 'repo1' }, artifactId: artifactId, }); // Mocks for work items to remove mockGetWorkItem.mockResolvedValueOnce({ relations: [ { rel: 'ArtifactLink', url: artifactId, // Use the same artifactId here attributes: { name: 'Pull Request', }, }, ], }); mockGetWorkItem.mockResolvedValueOnce({ relations: [ { rel: 'ArtifactLink', url: artifactId, // Use the same artifactId here attributes: { name: 'Pull Request', }, }, ], }); await updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, addWorkItemIds: [456, 789], removeWorkItemIds: [101, 202], }); // Check that updateWorkItem was called for adding work items expect(mockUpdateWorkItem).toHaveBeenCalledTimes(4); // 2 for add, 2 for remove expect(mockUpdateWorkItem).toHaveBeenCalledWith( null, [ { op: 'add', path: '/relations/-', value: { rel: 'ArtifactLink', url: 'vstfs:///Git/PullRequestId/project-1/repo1/123', attributes: { name: 'Pull Request', }, }, }, ], 456, ); // Check for removing work items expect(mockUpdateWorkItem).toHaveBeenCalledWith( null, [ { op: 'remove', path: '/relations/0', }, ], 101, ); }); it('should wrap unexpected errors in a friendly error message', async () => { mockGetPullRequestById.mockRejectedValueOnce(new Error('Unexpected')); await expect( updatePullRequest({ projectId: 'project-1', repositoryId: 'repo1', pullRequestId: 123, }), ).rejects.toThrow(AzureDevOpsError); }); }); ``` -------------------------------------------------------------------------------- /project-management/planning/azure-identity-authentication-design.md: -------------------------------------------------------------------------------- ```markdown # Azure Identity Authentication for Azure DevOps MCP Server This document outlines the implementation approach for adding Azure Identity authentication support to the Azure DevOps MCP Server. ## Overview The Azure DevOps MCP Server currently supports Personal Access Token (PAT) authentication. This enhancement will add support for Azure Identity authentication methods, specifically DefaultAzureCredential and AzureCliCredential, to provide more flexible authentication options for different environments. ## Azure Identity SDK The `@azure/identity` package provides various credential types for authenticating with Azure services. For our implementation, we will focus on the following credential types: ### DefaultAzureCredential `DefaultAzureCredential` provides a simplified authentication experience by trying multiple credential types in sequence: 1. Environment variables (EnvironmentCredential) 2. Managed Identity (ManagedIdentityCredential) 3. Azure CLI (AzureCliCredential) 4. Visual Studio Code (VisualStudioCodeCredential) 5. Azure PowerShell (AzurePowerShellCredential) 6. Interactive Browser (InteractiveBrowserCredential) - optional, disabled by default This makes it ideal for applications that need to work in different environments (local development, Azure-hosted) without code changes. ### AzureCliCredential `AzureCliCredential` authenticates using the Azure CLI's logged-in account. It requires the Azure CLI to be installed and the user to be logged in (`az login`). This is particularly useful for local development scenarios where developers are already using the Azure CLI. ## Implementation Approach ### 1. Authentication Abstraction Layer Create an abstraction layer for authentication that supports both PAT and Azure Identity methods: ```typescript // src/api/auth.ts export interface AuthProvider { getConnection(): Promise<WebApi>; isAuthenticated(): Promise<boolean>; } export class PatAuthProvider implements AuthProvider { // Existing PAT authentication implementation } export class AzureIdentityAuthProvider implements AuthProvider { // New Azure Identity authentication implementation } ``` ### 2. Authentication Factory Implement a factory pattern to create the appropriate authentication provider based on configuration: ```typescript // src/api/auth.ts export enum AuthMethod { PAT = 'pat', AZURE_IDENTITY = 'azure-identity', } export function createAuthProvider(config: AzureDevOpsConfig): AuthProvider { switch (config.authMethod) { case AuthMethod.AZURE_IDENTITY: return new AzureIdentityAuthProvider(config); case AuthMethod.PAT: default: return new PatAuthProvider(config); } } ``` ### 3. Azure Identity Authentication Provider Implement the Azure Identity authentication provider: ```typescript // src/api/auth.ts export class AzureIdentityAuthProvider implements AuthProvider { private config: AzureDevOpsConfig; private connectionPromise: Promise<WebApi> | null = null; constructor(config: AzureDevOpsConfig) { this.config = config; } async getConnection(): Promise<WebApi> { if (!this.connectionPromise) { this.connectionPromise = this.createConnection(); } return this.connectionPromise; } private async createConnection(): Promise<WebApi> { try { // Azure DevOps resource ID for token scope const azureDevOpsResourceId = '499b84ac-1321-427f-aa17-267ca6975798'; // Create credential based on configuration const credential = this.createCredential(); // Get token for Azure DevOps const token = await credential.getToken( `${azureDevOpsResourceId}/.default`, ); if (!token) { throw new AzureDevOpsAuthenticationError( 'Failed to acquire token from Azure Identity', ); } // Create auth handler with token const authHandler = new BearerCredentialHandler(token.token); // Create WebApi client const connection = new WebApi(this.config.organizationUrl, authHandler); // Test the connection await connection.getLocationsApi(); return connection; } catch (error) { throw new AzureDevOpsAuthenticationError( `Failed to authenticate with Azure Identity: ${error instanceof Error ? error.message : String(error)}`, ); } } private createCredential(): TokenCredential { if (this.config.azureIdentityOptions?.useAzureCliCredential) { return new AzureCliCredential(); } // Default to DefaultAzureCredential return new DefaultAzureCredential(); } async isAuthenticated(): Promise<boolean> { try { await this.getConnection(); return true; } catch { return false; } } } ``` ### 4. Configuration Updates Update the configuration interface to support specifying the authentication method: ```typescript // src/types/config.ts export interface AzureDevOpsConfig { // Existing properties organizationUrl: string; personalAccessToken?: string; defaultProject?: string; apiVersion?: string; // New properties authMethod?: AuthMethod; azureIdentityOptions?: { useAzureCliCredential?: boolean; // Other Azure Identity options as needed }; } ``` ### 5. Environment Variable Updates Update the environment variable handling in `index.ts`: ```typescript // src/index.ts const config: AzureDevOpsConfig = { organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', personalAccessToken: process.env.AZURE_DEVOPS_PAT, defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, apiVersion: process.env.AZURE_DEVOPS_API_VERSION, authMethod: (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthMethod) || AuthMethod.PAT, azureIdentityOptions: { useAzureCliCredential: process.env.AZURE_DEVOPS_USE_CLI_CREDENTIAL === 'true', }, }; ``` ### 6. Client Updates Update the `AzureDevOpsClient` class to use the authentication provider: ```typescript // src/api/client.ts export class AzureDevOpsClient { private authProvider: AuthProvider; constructor(config: AzureDevOpsConfig) { this.authProvider = createAuthProvider(config); } private async getClient(): Promise<WebApi> { return this.authProvider.getConnection(); } // Rest of the class remains the same } ``` ## Error Handling Implement proper error handling for Azure Identity authentication failures: ```typescript // src/common/errors.ts export class AzureIdentityAuthenticationError extends AzureDevOpsAuthenticationError { constructor(message: string) { super(`Azure Identity Authentication Error: ${message}`); } } ``` ## Configuration Examples ### PAT Authentication ```env AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org AZURE_DEVOPS_PAT=your-pat AZURE_DEVOPS_AUTH_METHOD=pat ``` ### DefaultAzureCredential Authentication ```env AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org AZURE_DEVOPS_AUTH_METHOD=azure-identity # Optional environment variables for specific credential types AZURE_TENANT_ID=your-tenant-id AZURE_CLIENT_ID=your-client-id AZURE_CLIENT_SECRET=your-client-secret ``` ### AzureCliCredential Authentication ```env AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org AZURE_DEVOPS_AUTH_METHOD=azure-cli ``` ``` -------------------------------------------------------------------------------- /src/features/work-items/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { isWorkItemsRequest, handleWorkItemsRequest } from './'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { WebApi } from 'azure-devops-node-api'; import * as workItemModule from './'; // Mock the imported modules jest.mock('./get-work-item', () => ({ getWorkItem: jest.fn(), })); jest.mock('./list-work-items', () => ({ listWorkItems: jest.fn(), })); jest.mock('./create-work-item', () => ({ createWorkItem: jest.fn(), })); jest.mock('./update-work-item', () => ({ updateWorkItem: jest.fn(), })); jest.mock('./manage-work-item-link', () => ({ manageWorkItemLink: jest.fn(), })); // Helper function to create a valid CallToolRequest object const createCallToolRequest = (name: string, args: any): CallToolRequest => { return { method: 'tools/call', params: { name, arguments: args, }, } as unknown as CallToolRequest; }; describe('Work Items Request Handlers', () => { describe('isWorkItemsRequest', () => { it('should return true for work items requests', () => { const workItemsRequests = [ 'get_work_item', 'list_work_items', 'create_work_item', 'update_work_item', 'manage_work_item_link', ]; workItemsRequests.forEach((name) => { const request = createCallToolRequest(name, {}); expect(isWorkItemsRequest(request)).toBe(true); }); }); it('should return false for non-work items requests', () => { const request = createCallToolRequest('get_project', {}); expect(isWorkItemsRequest(request)).toBe(false); }); }); describe('handleWorkItemsRequest', () => { let mockConnection: WebApi; beforeEach(() => { mockConnection = {} as WebApi; // Setup mock for schema validation - with correct return types jest .spyOn(workItemModule.GetWorkItemSchema, 'parse') .mockImplementation(() => { return { workItemId: 123, expand: undefined }; }); jest .spyOn(workItemModule.ListWorkItemsSchema, 'parse') .mockImplementation(() => { return { projectId: 'myProject' }; }); jest .spyOn(workItemModule.CreateWorkItemSchema, 'parse') .mockImplementation(() => { return { projectId: 'myProject', workItemType: 'Task', title: 'New Task', }; }); jest .spyOn(workItemModule.UpdateWorkItemSchema, 'parse') .mockImplementation(() => { return { workItemId: 123, title: 'Updated Title', }; }); jest .spyOn(workItemModule.ManageWorkItemLinkSchema, 'parse') .mockImplementation(() => { return { sourceWorkItemId: 123, targetWorkItemId: 456, operation: 'add' as 'add' | 'remove' | 'update', relationType: 'System.LinkTypes.Hierarchy-Forward', }; }); // Setup mocks for feature functions jest.spyOn(workItemModule, 'getWorkItem').mockResolvedValue({ id: 123 }); jest .spyOn(workItemModule, 'listWorkItems') .mockResolvedValue([{ id: 123 }, { id: 456 }]); jest .spyOn(workItemModule, 'createWorkItem') .mockResolvedValue({ id: 789 }); jest .spyOn(workItemModule, 'updateWorkItem') .mockResolvedValue({ id: 123 }); jest .spyOn(workItemModule, 'manageWorkItemLink') .mockResolvedValue({ id: 123 }); }); afterEach(() => { jest.resetAllMocks(); }); it('should handle get_work_item requests', async () => { const request = createCallToolRequest('get_work_item', { workItemId: 123, }); const result = await handleWorkItemsRequest(mockConnection, request); expect(workItemModule.GetWorkItemSchema.parse).toHaveBeenCalledWith({ workItemId: 123, }); expect(workItemModule.getWorkItem).toHaveBeenCalledWith( mockConnection, 123, undefined, ); expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify({ id: 123 }, null, 2) }], }); }); it('should handle list_work_items requests', async () => { const request = createCallToolRequest('list_work_items', { projectId: 'myProject', }); const result = await handleWorkItemsRequest(mockConnection, request); expect(workItemModule.ListWorkItemsSchema.parse).toHaveBeenCalledWith({ projectId: 'myProject', }); expect(workItemModule.listWorkItems).toHaveBeenCalled(); expect(result).toEqual({ content: [ { type: 'text', text: JSON.stringify([{ id: 123 }, { id: 456 }], null, 2), }, ], }); }); it('should handle create_work_item requests', async () => { const request = createCallToolRequest('create_work_item', { projectId: 'myProject', workItemType: 'Task', title: 'New Task', }); const result = await handleWorkItemsRequest(mockConnection, request); expect(workItemModule.CreateWorkItemSchema.parse).toHaveBeenCalledWith({ projectId: 'myProject', workItemType: 'Task', title: 'New Task', }); expect(workItemModule.createWorkItem).toHaveBeenCalled(); expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify({ id: 789 }, null, 2) }], }); }); it('should handle update_work_item requests', async () => { const request = createCallToolRequest('update_work_item', { workItemId: 123, title: 'Updated Title', }); const result = await handleWorkItemsRequest(mockConnection, request); expect(workItemModule.UpdateWorkItemSchema.parse).toHaveBeenCalledWith({ workItemId: 123, title: 'Updated Title', }); expect(workItemModule.updateWorkItem).toHaveBeenCalled(); expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify({ id: 123 }, null, 2) }], }); }); it('should handle manage_work_item_link requests', async () => { const request = createCallToolRequest('manage_work_item_link', { sourceWorkItemId: 123, targetWorkItemId: 456, operation: 'add', relationType: 'System.LinkTypes.Hierarchy-Forward', }); const result = await handleWorkItemsRequest(mockConnection, request); expect( workItemModule.ManageWorkItemLinkSchema.parse, ).toHaveBeenCalledWith({ sourceWorkItemId: 123, targetWorkItemId: 456, operation: 'add', relationType: 'System.LinkTypes.Hierarchy-Forward', }); expect(workItemModule.manageWorkItemLink).toHaveBeenCalled(); expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify({ id: 123 }, null, 2) }], }); }); it('should throw an error for unknown work items tools', async () => { const request = createCallToolRequest('unknown_tool', {}); await expect( handleWorkItemsRequest(mockConnection, request), ).rejects.toThrow('Unknown work items tool: unknown_tool'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/work-items/get-work-item/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getWorkItem } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '../__test__/test-helpers'; import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; import { createWorkItem } from '../create-work-item/feature'; import { manageWorkItemLink } from '../manage-work-item-link/feature'; import { CreateWorkItemOptions } from '../types'; describe('getWorkItem integration', () => { let connection: WebApi | null = null; let testWorkItemId: number | null = null; let linkedWorkItemId: number | 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'; // Skip setup if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { return; } try { // Create a test work item const uniqueTitle = `Test Work Item ${new Date().toISOString()}`; const options: CreateWorkItemOptions = { title: uniqueTitle, description: 'Test work item for get-work-item integration tests', }; const testWorkItem = await createWorkItem( connection, projectName, 'Task', options, ); // Create another work item to link to the first one const linkedItemOptions: CreateWorkItemOptions = { title: `Linked Work Item ${new Date().toISOString()}`, description: 'Linked work item for get-work-item integration tests', }; const linkedWorkItem = await createWorkItem( connection, projectName, 'Task', linkedItemOptions, ); if (testWorkItem?.id && linkedWorkItem?.id) { testWorkItemId = testWorkItem.id; linkedWorkItemId = linkedWorkItem.id; // Create a link between the two work items await manageWorkItemLink(connection, projectName, { sourceWorkItemId: testWorkItemId, targetWorkItemId: linkedWorkItemId, operation: 'add', relationType: 'System.LinkTypes.Related', comment: 'Link created for get-work-item integration tests', }); } } catch (error) { console.error('Failed to create test work items:', error); } }); test('should retrieve a real work item from Azure DevOps with default expand=all', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { return; } // Act - get work item by ID const result = await getWorkItem(connection, testWorkItemId); // Assert expect(result).toBeDefined(); expect(result.id).toBe(testWorkItemId); // Verify expanded fields and data are present expect(result.fields).toBeDefined(); expect(result._links).toBeDefined(); // With expand=all and a linked item, relations should be defined expect(result.relations).toBeDefined(); if (result.fields) { // Verify common fields that should be present with expand=all expect(result.fields['System.Title']).toBeDefined(); expect(result.fields['System.State']).toBeDefined(); expect(result.fields['System.CreatedDate']).toBeDefined(); expect(result.fields['System.ChangedDate']).toBeDefined(); } }); test('should retrieve work item with expanded relations', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { return; } // Act - get work item with relations expansion const result = await getWorkItem(connection, testWorkItemId, 'relations'); // Assert expect(result).toBeDefined(); expect(result.id).toBe(testWorkItemId); // When using expand=relations on a work item with links, relations should be defined expect(result.relations).toBeDefined(); // Verify we can access the related work item if (result.relations && result.relations.length > 0) { const relation = result.relations[0]; expect(relation.rel).toBe('System.LinkTypes.Related'); expect(relation.url).toContain(linkedWorkItemId?.toString()); } // Verify fields exist expect(result.fields).toBeDefined(); if (result.fields) { expect(result.fields['System.Title']).toBeDefined(); } }); test('should retrieve work item with minimal fields when using expand=none', async () => { if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { return; } // Act - get work item with no expansion const result = await getWorkItem(connection, testWorkItemId, 'none'); // Assert expect(result).toBeDefined(); expect(result.id).toBe(testWorkItemId); expect(result.fields).toBeDefined(); // With expand=none, we should still get _links but no relations // The Azure DevOps API still returns _links even with expand=none expect(result.relations).toBeUndefined(); }); test('should throw AzureDevOpsResourceNotFoundError for non-existent work item', async () => { if (shouldSkipIntegrationTest() || !connection) { return; } // Use a very large ID that's unlikely to exist const nonExistentId = 999999999; // Assert that it throws the correct error await expect(getWorkItem(connection, nonExistentId)).rejects.toThrow( AzureDevOpsResourceNotFoundError, ); }); test('should include all possible fields with null values for empty fields', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { return; } // Act - get work item by ID const result = await getWorkItem(connection, testWorkItemId); // Assert expect(result).toBeDefined(); expect(result.fields).toBeDefined(); if (result.fields) { // Get a direct connection to WorkItemTrackingApi to fetch field info for comparison const witApi = await connection.getWorkItemTrackingApi(); const projectName = result.fields['System.TeamProject']; const workItemType = result.fields['System.WorkItemType']; expect(projectName).toBeDefined(); expect(workItemType).toBeDefined(); if (projectName && workItemType) { // Get all possible field references for this work item type const allFields = await witApi.getWorkItemTypeFieldsWithReferences( projectName.toString(), workItemType.toString(), ); // Check that all fields from the reference are present in the result // Some might be null, but they should exist in the fields object for (const field of allFields) { if (field.referenceName) { expect(Object.keys(result.fields)).toContain(field.referenceName); } } // There should be at least one field with a null value // (This is a probabilistic test but very likely to pass since work items // typically have many optional fields that aren't filled in) const hasNullField = Object.values(result.fields).some( (value) => value === null, ); expect(hasNullField).toBe(true); } } }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { isWikisRequest, handleWikisRequest } from './index'; import { getWikis, GetWikisSchema } from './get-wikis'; import { getWikiPage, GetWikiPageSchema } from './get-wiki-page'; import { createWiki, CreateWikiSchema, WikiType } from './create-wiki'; import { updateWikiPage, UpdateWikiPageSchema } from './update-wiki-page'; // Mock the imported modules jest.mock('./get-wikis', () => ({ getWikis: jest.fn(), GetWikisSchema: { parse: jest.fn(), }, })); jest.mock('./get-wiki-page', () => ({ getWikiPage: jest.fn(), GetWikiPageSchema: { parse: jest.fn(), }, })); jest.mock('./create-wiki', () => ({ createWiki: jest.fn(), CreateWikiSchema: { parse: jest.fn(), }, WikiType: { ProjectWiki: 'projectWiki', CodeWiki: 'codeWiki', }, })); jest.mock('./update-wiki-page', () => ({ updateWikiPage: jest.fn(), UpdateWikiPageSchema: { parse: jest.fn(), }, })); describe('Wikis Request Handlers', () => { const mockConnection = {} as WebApi; describe('isWikisRequest', () => { it('should return true for wikis requests', () => { const validTools = [ 'get_wikis', 'get_wiki_page', 'create_wiki', 'update_wiki_page', ]; validTools.forEach((tool) => { const request = { params: { name: tool, arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isWikisRequest(request)).toBe(true); }); }); it('should return false for non-wikis requests', () => { const request = { params: { name: 'list_projects', arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isWikisRequest(request)).toBe(false); }); }); describe('handleWikisRequest', () => { it('should handle get_wikis request', async () => { const mockWikis = [ { id: 'wiki1', name: 'Wiki 1' }, { id: 'wiki2', name: 'Wiki 2' }, ]; (getWikis as jest.Mock).mockResolvedValue(mockWikis); const request = { params: { name: 'get_wikis', arguments: { projectId: 'project1', }, }, method: 'tools/call', } as CallToolRequest; // Mock the arguments object after parsing (GetWikisSchema.parse as jest.Mock).mockReturnValue({ projectId: 'project1', }); const response = await handleWikisRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual(mockWikis); expect(getWikis).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ projectId: 'project1', }), ); }); it('should handle get_wiki_page request', async () => { const mockWikiContent = '# Wiki Page\n\nThis is a wiki page content.'; (getWikiPage as jest.Mock).mockResolvedValue(mockWikiContent); const request = { params: { name: 'get_wiki_page', arguments: { projectId: 'project1', wikiId: 'wiki1', pagePath: '/Home', }, }, method: 'tools/call', } as CallToolRequest; // Mock the arguments object after parsing (GetWikiPageSchema.parse as jest.Mock).mockReturnValue({ projectId: 'project1', wikiId: 'wiki1', pagePath: '/Home', }); const response = await handleWikisRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(response.content[0].text as string).toEqual(mockWikiContent); expect(getWikiPage).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project1', wikiId: 'wiki1', pagePath: '/Home', }), ); }); it('should handle create_wiki request', async () => { const mockWiki = { id: 'wiki1', name: 'New Wiki' }; (createWiki as jest.Mock).mockResolvedValue(mockWiki); const request = { params: { name: 'create_wiki', arguments: { projectId: 'project1', name: 'New Wiki', type: WikiType.ProjectWiki, }, }, method: 'tools/call', } as CallToolRequest; // Mock the arguments object after parsing (CreateWikiSchema.parse as jest.Mock).mockReturnValue({ projectId: 'project1', name: 'New Wiki', type: WikiType.ProjectWiki, mappedPath: null, // Required field in the schema }); const response = await handleWikisRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual(mockWiki); expect(createWiki).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ projectId: 'project1', name: 'New Wiki', type: WikiType.ProjectWiki, }), ); }); it('should handle update_wiki_page request', async () => { const mockUpdateResult = { id: 'page1', content: 'Updated content' }; (updateWikiPage as jest.Mock).mockResolvedValue(mockUpdateResult); const request = { params: { name: 'update_wiki_page', arguments: { projectId: 'project1', wikiId: 'wiki1', pagePath: '/Home', content: 'Updated content', comment: 'Update home page', }, }, method: 'tools/call', } as CallToolRequest; // Mock the arguments object after parsing (UpdateWikiPageSchema.parse as jest.Mock).mockReturnValue({ projectId: 'project1', wikiId: 'wiki1', pagePath: '/Home', content: 'Updated content', comment: 'Update home page', }); const response = await handleWikisRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockUpdateResult, ); expect(updateWikiPage).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project1', wikiId: 'wiki1', pagePath: '/Home', content: 'Updated content', comment: 'Update home page', }), ); }); it('should throw error for unknown tool', async () => { const request = { params: { name: 'unknown_tool', arguments: {}, }, method: 'tools/call', } as CallToolRequest; await expect(handleWikisRequest(mockConnection, request)).rejects.toThrow( 'Unknown wikis tool', ); }); it('should propagate errors from wiki functions', async () => { const mockError = new Error('Test error'); (getWikis as jest.Mock).mockRejectedValue(mockError); const request = { params: { name: 'get_wikis', arguments: { projectId: 'project1', }, }, method: 'tools/call', } as CallToolRequest; // Mock the arguments object after parsing (GetWikisSchema.parse as jest.Mock).mockReturnValue({ projectId: 'project1', }); await expect(handleWikisRequest(mockConnection, request)).rejects.toThrow( mockError, ); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/pull-requests/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { isPullRequestsRequest, handlePullRequestsRequest } from './index'; import { createPullRequest } from './create-pull-request'; import { listPullRequests } from './list-pull-requests'; import { getPullRequestComments } from './get-pull-request-comments'; import { addPullRequestComment } from './add-pull-request-comment'; import { AddPullRequestCommentSchema } from './schemas'; // Mock the imported modules jest.mock('./create-pull-request', () => ({ createPullRequest: jest.fn(), })); jest.mock('./list-pull-requests', () => ({ listPullRequests: jest.fn(), })); jest.mock('./get-pull-request-comments', () => ({ getPullRequestComments: jest.fn(), })); jest.mock('./add-pull-request-comment', () => ({ addPullRequestComment: jest.fn(), })); describe('Pull Requests Request Handlers', () => { const mockConnection = {} as WebApi; describe('isPullRequestsRequest', () => { it('should return true for pull requests tools', () => { const validTools = [ 'create_pull_request', 'list_pull_requests', 'get_pull_request_comments', 'add_pull_request_comment', ]; validTools.forEach((tool) => { const request = { params: { name: tool, arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isPullRequestsRequest(request)).toBe(true); }); }); it('should return false for non-pull requests tools', () => { const request = { params: { name: 'list_projects', arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isPullRequestsRequest(request)).toBe(false); }); }); describe('handlePullRequestsRequest', () => { it('should handle create_pull_request request', async () => { const mockPullRequest = { id: 1, title: 'Test PR' }; (createPullRequest as jest.Mock).mockResolvedValue(mockPullRequest); const request = { params: { name: 'create_pull_request', arguments: { repositoryId: 'test-repo', title: 'Test PR', sourceRefName: 'refs/heads/feature', targetRefName: 'refs/heads/main', }, }, method: 'tools/call', } as CallToolRequest; const response = await handlePullRequestsRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockPullRequest, ); expect(createPullRequest).toHaveBeenCalledWith( mockConnection, expect.any(String), 'test-repo', expect.objectContaining({ title: 'Test PR', sourceRefName: 'refs/heads/feature', targetRefName: 'refs/heads/main', }), ); }); it('should handle list_pull_requests request', async () => { const mockPullRequests = { count: 2, value: [ { id: 1, title: 'PR 1' }, { id: 2, title: 'PR 2' }, ], hasMoreResults: false, }; (listPullRequests as jest.Mock).mockResolvedValue(mockPullRequests); const request = { params: { name: 'list_pull_requests', arguments: { repositoryId: 'test-repo', status: 'active', }, }, method: 'tools/call', } as CallToolRequest; const response = await handlePullRequestsRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockPullRequests, ); expect(listPullRequests).toHaveBeenCalledWith( mockConnection, expect.any(String), 'test-repo', expect.objectContaining({ status: 'active', }), ); }); it('should handle get_pull_request_comments request', async () => { const mockComments = { threads: [ { id: 1, comments: [{ id: 1, content: 'Comment 1' }], }, ], }; (getPullRequestComments as jest.Mock).mockResolvedValue(mockComments); const request = { params: { name: 'get_pull_request_comments', arguments: { repositoryId: 'test-repo', pullRequestId: 123, }, }, method: 'tools/call', } as CallToolRequest; const response = await handlePullRequestsRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockComments, ); expect(getPullRequestComments).toHaveBeenCalledWith( mockConnection, expect.any(String), 'test-repo', 123, expect.objectContaining({ pullRequestId: 123, }), ); }); it('should handle add_pull_request_comment request', async () => { const mockResult = { comment: { id: 1, content: 'New comment' }, thread: { id: 1 }, }; (addPullRequestComment as jest.Mock).mockResolvedValue(mockResult); const request = { params: { name: 'add_pull_request_comment', arguments: { repositoryId: 'test-repo', pullRequestId: 123, content: 'New comment', status: 'active', // Status is required when creating a new thread }, }, method: 'tools/call', } as CallToolRequest; // Mock the schema parsing const mockParsedArgs = { repositoryId: 'test-repo', pullRequestId: 123, content: 'New comment', status: 'active', }; // Use a different approach for mocking const originalParse = AddPullRequestCommentSchema.parse; AddPullRequestCommentSchema.parse = jest .fn() .mockReturnValue(mockParsedArgs); const response = await handlePullRequestsRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockResult, ); expect(addPullRequestComment).toHaveBeenCalledWith( mockConnection, expect.any(String), 'test-repo', 123, expect.objectContaining({ content: 'New comment', }), ); // Restore the original parse function AddPullRequestCommentSchema.parse = originalParse; }); it('should throw error for unknown tool', async () => { const request = { params: { name: 'unknown_tool', arguments: {}, }, method: 'tools/call', } as CallToolRequest; await expect( handlePullRequestsRequest(mockConnection, request), ).rejects.toThrow('Unknown pull requests tool'); }); it('should propagate errors from pull request functions', async () => { const mockError = new Error('Test error'); (listPullRequests as jest.Mock).mockRejectedValue(mockError); const request = { params: { name: 'list_pull_requests', arguments: { repositoryId: 'test-repo', }, }, method: 'tools/call', } as CallToolRequest; await expect( handlePullRequestsRequest(mockConnection, request), ).rejects.toThrow(mockError); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/pull-requests/add-pull-request-comment/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { addPullRequestComment } from './feature'; import { Comment, CommentThreadStatus, CommentType, GitPullRequestCommentThread, } from 'azure-devops-node-api/interfaces/GitInterfaces'; describe('addPullRequestComment', () => { afterEach(() => { jest.resetAllMocks(); }); test('should add a comment to an existing thread successfully', async () => { // Mock data for a new comment const mockComment: Comment = { id: 101, content: 'This is a reply comment', commentType: CommentType.Text, author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }; // Setup mock connection const mockGitApi = { createComment: jest.fn().mockResolvedValue(mockComment), createThread: jest.fn(), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const threadId = 456; const options = { projectId, repositoryId, pullRequestId, threadId, content: 'This is a reply comment', }; const result = await addPullRequestComment( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ); // Verify results (with transformed commentType) expect(result).toEqual({ comment: { ...mockComment, commentType: 'text', // Transform enum to string }, }); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.createComment).toHaveBeenCalledTimes(1); expect(mockGitApi.createComment).toHaveBeenCalledWith( expect.objectContaining({ content: 'This is a reply comment' }), repositoryId, pullRequestId, threadId, projectId, ); expect(mockGitApi.createThread).not.toHaveBeenCalled(); }); test('should create a new thread with a comment successfully', async () => { // Mock data for a new thread with comment const mockComment: Comment = { id: 100, content: 'This is a new comment', commentType: CommentType.Text, author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }; const mockThread: GitPullRequestCommentThread = { id: 789, comments: [mockComment], status: CommentThreadStatus.Active, }; // Setup mock connection const mockGitApi = { createComment: jest.fn(), createThread: jest.fn().mockResolvedValue(mockThread), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const options = { projectId, repositoryId, pullRequestId, content: 'This is a new comment', status: 'active' as const, }; const result = await addPullRequestComment( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ); // Verify results expect(result).toEqual({ comment: { ...mockComment, commentType: 'text', }, thread: { ...mockThread, status: 'active', comments: mockThread.comments?.map((comment) => ({ ...comment, commentType: 'text', })), }, }); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.createThread).toHaveBeenCalledTimes(1); expect(mockGitApi.createThread).toHaveBeenCalledWith( expect.objectContaining({ comments: [ expect.objectContaining({ content: 'This is a new comment' }), ], status: CommentThreadStatus.Active, }), repositoryId, pullRequestId, projectId, ); expect(mockGitApi.createComment).not.toHaveBeenCalled(); }); test('should create a new thread on a file with line number', async () => { // Mock data for a new thread with comment on file const mockComment: Comment = { id: 100, content: 'This code needs improvement', commentType: CommentType.Text, author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }; const mockThread: GitPullRequestCommentThread = { id: 789, status: CommentThreadStatus.Active, // Add missing status comments: [mockComment], threadContext: { filePath: '/src/app.ts', rightFileStart: { line: 42, offset: 1, }, rightFileEnd: { line: 42, offset: 1, }, }, }; // Setup mock connection const mockGitApi = { createComment: jest.fn(), createThread: jest.fn().mockResolvedValue(mockThread), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const options = { projectId, repositoryId, pullRequestId, content: 'This code needs improvement', filePath: '/src/app.ts', lineNumber: 42, }; const result = await addPullRequestComment( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ); // Verify results expect(result).toEqual({ comment: { ...mockComment, commentType: 'text', }, thread: { ...mockThread, status: 'active', comments: mockThread.comments?.map((comment) => ({ ...comment, commentType: 'text', })), }, }); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.createThread).toHaveBeenCalledTimes(1); expect(mockGitApi.createThread).toHaveBeenCalledWith( expect.objectContaining({ comments: [ expect.objectContaining({ content: 'This code needs improvement' }), ], threadContext: expect.objectContaining({ filePath: '/src/app.ts', rightFileStart: expect.objectContaining({ line: 42 }), rightFileEnd: expect.objectContaining({ line: 42 }), }), }), repositoryId, pullRequestId, projectId, ); expect(mockGitApi.createComment).not.toHaveBeenCalled(); }); test('should handle error when API call fails', async () => { // Setup mock connection with error const errorMessage = 'API error'; const mockGitApi = { createComment: jest.fn().mockRejectedValue(new Error(errorMessage)), createThread: jest.fn(), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const threadId = 456; const options = { projectId, repositoryId, pullRequestId, threadId, content: 'This is a reply comment', }; // Verify error handling await expect( addPullRequestComment( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ), ).rejects.toThrow(`Failed to add pull request comment: ${errorMessage}`); }); }); ``` -------------------------------------------------------------------------------- /project-management/troubleshooter.xml: -------------------------------------------------------------------------------- ``` <TroubleshootingGuide> <Flowchart> graph TD A[Start: Identify Issue] --> B[Gather Information] B --> C[Form Hypotheses] C --> D[Test Hypotheses] D -->|Issue Resolved?| E[Implement Solution] D -->|Issue Persists| B E --> F[Verify Fix] F -->|Success| G[Document & Conclude] F -->|Failure| B </Flowchart> <Introduction> Troubleshooting is an essential skill that remains vital across all fields, from software development to mechanical engineering and household repairs. It’s the art of systematically identifying and resolving problems, a competency that never goes obsolete due to its universal relevance. Whether you’re debugging a crashing application, fixing a car that won’t start, or repairing a leaky faucet, troubleshooting empowers you to tackle challenges methodically. This guide provides a framework that adapts to any domain, highlighting the importance of a structured approach and a problem-solving mindset. </Introduction> <Preparation> Preparation is the foundation of effective troubleshooting. Before addressing any issue, take these steps: 1. **Gather Necessary Tools:** Equip yourself with the right resources—debugging software for coding, wrenches and multimeters for machinery, or a toolkit with pliers and tape for home repairs. 2. **Understand the System:** Study the system or device involved. Review code documentation, machine schematics, or appliance manuals to grasp how it should function. 3. **Ensure Safety:** Prioritize safety by disconnecting power, wearing protective gear, or shutting off water supplies as needed. 4. **Document the Initial State:** Note symptoms, error messages, or unusual behaviors (e.g., a software error code, a grinding noise from an engine, or water pooling under a sink) to establish a baseline. Proper preparation minimizes guesswork and sets the stage for efficient problem-solving. </Preparation> <Diagnosis> Diagnosis is the core of troubleshooting, requiring a systematic approach to uncover the root cause: 1. **Gather Information:** Collect data through observation or tools—check software logs, listen for mechanical noises, or inspect pipes for leaks. 2. **Form Hypotheses:** Develop theories about the cause based on evidence. For a software bug, suspect a recent code change; for a car, consider a dead battery; for a leak, think of a loose seal. 3. **Test Hypotheses:** Conduct targeted tests—run a software debug session, measure battery voltage with a multimeter, or tighten a pipe fitting and check for drips. 4. **Analyze Results:** Assess test outcomes to confirm or adjust your hypotheses. If the issue persists, gather more data and refine your theories. For example, in software, replicate a crash and trace it to a faulty loop; in mechanical engineering, test a pump after hearing a whine; in household repairs, turn on water to locate a drip’s source. This process is iterative—loop back as needed until the problem is clear. </Diagnosis> <SolutionImplementation> With the cause identified, implement a solution methodically: 1. **Prioritize Fixes:** Address critical issues first—fix a server outage before a minor UI glitch, replace a broken engine belt before tuning performance, or stop a major leak before patching a crack. 2. **Apply the Solution:** Execute the fix—patch the code and deploy it, install a new part, or replace a worn washer. Follow best practices or guidelines specific to the domain. 3. **Test the Solution:** Verify the fix works—run the software, start the engine, or turn on the tap to ensure functionality. 4. **Document Changes:** Record actions taken, like code updates in a changelog, parts swapped in a maintenance log, or repair steps in a notebook, for future reference. Examples include deploying a software update and checking for crashes, replacing a car alternator and testing the charge, or sealing a pipe and ensuring no leaks remain. Precision here prevents new issues. </SolutionImplementation> <Verification> Verification confirms the issue is resolved and the system is stable: 1. **Perform Functional Tests:** Run the system normally—execute software features, drive the car, or use the repaired appliance. 2. **Check for Side Effects:** Look for unintended outcomes, like new software errors, engine vibrations, or damp spots near a fix. 3. **Monitor Over Time:** Observe performance longer-term—watch software logs for a day, run machinery through cycles, or check a pipe after hours of use. 4. **Get User Feedback:** If applicable, ask users (e.g., software testers, car owners, or household members) to confirm the problem is gone. For instance, monitor a web app post-fix for uptime, test a repaired tractor under load, or ensure a faucet stays dry overnight. Thorough verification ensures lasting success. </Verification> <CommonMistakes> Avoid these pitfalls to troubleshoot effectively: 1. **Jumping to Conclusions:** Assuming a software crash is a server issue without logs, or replacing an engine part without testing, wastes time—always validate hypotheses. 2. **Neglecting Documentation:** Skipping notes on code changes or repair steps complicates future fixes—keep detailed records. 3. **Overlooking Simple Solutions:** A reboot might fix a software glitch, or a loose bolt could be the mechanical issue—check the obvious first. 4. **Insufficient Testing:** Deploying a patch without full tests, or assuming a pipe is fixed after a quick look, risks recurrence—test rigorously. 5. **Ignoring Safety:** Debugging live circuits or repairing plumbing without shutoffs invites danger—prioritize safety always. Awareness of these errors keeps your process on track. </CommonMistakes> <MindsetTips> Cultivate this mindset for troubleshooting success: 1. **Patience:** Problems may resist quick fixes—stay calm when a bug eludes you or a repair takes hours. 2. **Attention to Detail:** Notice subtle clues—a log timestamp, a faint hum, or a drip pattern can crack the case. 3. **Persistence:** If a fix fails, keep testing—don’t abandon a software trace or a machine teardown midstream. 4. **Open-Mindedness:** A bug might stem from an overlooked module, or a leak from an unexpected joint—stay flexible. 5. **Learning Orientation:** Each challenge teaches something—log a new coding trick, a mechanical quirk, or a repair tip. 6. **Collaboration:** Seek input—a colleague might spot a code flaw or a neighbor recall a similar fix. This mindset turns obstacles into opportunities. </MindsetTips> <Conclusion> Troubleshooting is a timeless skill that equips you to solve problems anywhere—from codebases to engines to homes. This guide’s systematic approach—preparing diligently, diagnosing precisely, implementing thoughtfully, and verifying completely—ensures success across domains. Avoiding common mistakes and embracing a resilient mindset amplify your effectiveness. Mastering troubleshooting not only boosts professional prowess but also fosters everyday resourcefulness, making it a skill worth honing for life. </Conclusion> </TroubleshootingGuide> ``` -------------------------------------------------------------------------------- /project-management/startup.xml: -------------------------------------------------------------------------------- ``` <AiTaskAgent> <GlobalRule alwaysApply="true">If an ANY point you get stuck, review troubleshooter.xml to help you troubleshoot the problem.</GlobalRule> <GlobalRule alwaysApply="true">All new code creation should ALWAYS follow tdd-cycle.xml</GlobalRule> <GlobalRule alwaysApply="true">Tasks in the GitHub project board at https://github.com/users/Tiberriver256/projects/1 are sorted in order of priority - ALWAYS pick the task from the top of the backlog column.</GlobalRule> <GlobalRule alwaysApply="true">Always use the GitHub CLI (gh) for project and issue management. If documentation is needed, use browser_navigate to access the documentation. Always use a markdown file for writing/updating issues rather than trying to work with cli args.</GlobalRule> <GlobalRule alwaysApply="true">There is a strict WIP limit of 1. If any issue is in the Research, Implementation, or In Review status, that issue MUST be completed before starting a new one from Backlog.</GlobalRule> <GlobalRule alwaysApply="true">We are always operating as the GitHub user 'Tiberriver256'. All issues must be assigned to 'Tiberriver256' before starting work on them.</GlobalRule> <GlobalRule alwaysApply="true">To update a project item status, first get the project ID with `gh project list --owner Tiberriver256 --format json`, then get the item ID with `gh project item-list [project-id] --format json`, and finally update the status with `gh project item-edit --id [item-id] --project-id [project-id] --field-id PVTSSF_lAHOAGqmtM4A2BrBzgrZoeI --single-select-option-id [status-id]`. Status IDs are: Backlog (f75ad846), Research (61e4505c), Implementation (47fc9ee4), In review (df73e18b), Done (98236657).</GlobalRule> <GlobalRule alwaysApply="true">To create a GitHub issue: 1) Create a markdown file for the issue body (e.g., `issue_body.md`), 2) Use `gh issue create --title "Issue Title" --body-file issue_body.md --label "enhancement"` to create the issue, 3) Assign it with `gh issue edit [issue-number] --add-assignee Tiberriver256`, 4) Add status label with `gh issue edit [issue-number] --add-label "status:research"`, 5) Add to project with `gh project item-add [project-id] --owner Tiberriver256 --url [issue-url]`, and 6) Update status in project using the project item-edit command as described above.</GlobalRule> <InitialSetup order="1"> <Step order="1">Read the dream team documentation at project-management/planning/the-dream-team.md to understand the team structure and roles</Step> <Step order="2">Read all files in the project-management/planning directory to understand the project architecture, features, and structure</Step> <Step order="3">Check if there is any issue in the GitHub project board at https://github.com/users/Tiberriver256/projects/1 with a status of "Research", "Implementation", or "In Review". Use 'gh project item-list' to check the current issues and their status.</Step> <Step order="4"> If there is any issue in "Research", "Implementation", or "In Review" status, ensure it is assigned to 'Tiberriver256' and work on that issue, moving directly into the appropriate phase of TaskWorkflow. If not, take the FIRST issue from the top of the "Backlog" status in the project board at https://github.com/users/Tiberriver256/projects/1, assign it to 'Tiberriver256', and update its status to "Research". Remember that issues are sorted by priority with most important at the top. Add a comment with your implementation approach and planned sub-tasks if needed. Use the GitHub CLI (gh) for all project and issue management. </Step> <Step order="5">Create a new branch for the current task, branching from the latest main branch. Use a descriptive name for the branch, related to the task, by running ./create_branch.sh <branch_name>.</Step> <Step order="6">Read tdd-cycle.xml to understand the TDD cycle.</Step> <Step order="7">Read all files in the docs/testing directory to understand the testing strategy.</Step> <Step order="8">Start the research phase of TaskWorkflow.</Step> </InitialSetup> <TaskWorkflow order="2"> <Phase name="Research" order="1"> <Step order="1">Make sure the issue is assigned to 'Tiberriver256' and its status is set to "Research" in the GitHub project board.</Step> <Step order="2">Research the selected GitHub issue thoroughly</Step> <Step order="3">Create notes in a comment on the GitHub issue about your approach. Use the GitHub CLI (gh) for interacting with issues.</Step> <Step order="4">Break down the task into sub-tasks only if necessary (prefer simplicity)</Step> <Step order="5">If the task is straightforward, keep it as a single task</Step> </Phase> <Phase name="Planning" order="2"> <STOPPING_POINT order="1">Present your sub-tasks (if any) and approach for approval</STOPPING_POINT> </Phase> <Phase name="Implementation" order="3"> <Step order="1">Update the issue status to "Implementation" in the GitHub project board, ensuring it remains assigned to 'Tiberriver256'.</Step> <Step order="2">Assume the role and persona of the team member assigned to the task</Step> <Step order="3">If multiple roles are involved, simulate pair/mob programming</Step> <Step order="4">Use Test-Driven Development for all coding tasks</Step> <Step order="5">Create any necessary readme.md files for documentation or reference</Step> </Phase> <Phase name="Completion" order="4"> <Step order="1">Create a pull request and update the issue status to "In Review" in the GitHub project board, ensuring it remains assigned to 'Tiberriver256'.</Step> <STOPPING_POINT order="2">Present your work for review</STOPPING_POINT> <Step order="3">Address any feedback, and present for re-review; Continue in this manner until approved</Step> <Step order="4">When the task is approved, run ./finish_task.sh "PR Title" "PR Description" to commit, push, and update the PR for the repository at https://github.com/Tiberriver256/mcp-server-azure-devops</Step> <Step order="5">After the PR is merged, update the issue status to "Done" and close the GitHub issue with an appropriate comment summarizing the work done. Use the GitHub CLI (gh) to close issues.</Step> <Step order="6">Wait for feedback before starting a new task</Step> </Phase> </TaskWorkflow> <WorkingPrinciples> <Principle>Use the tree command when exploring directory structures</Principle> <Principle>Follow KISS (Keep It Stupid Simple) and YAGNI (You Aren't Gonna Need It) principles</Principle> <Principle>Focus on delivery rather than over-engineering or gold-plating features</Principle> <Principle>Implement Test-Driven Development for all code</Principle> <Principle>Use the GitHub CLI (gh) for any GitHub-related tasks including project and issue management. Documentation is available at: - GitHub Projects CLI: https://cli.github.com/manual/gh_project - GitHub Issues CLI: https://cli.github.com/manual/gh_issue</Principle> <Principle>If GitHub CLI documentation is needed, use browser_navigate to access documentation</Principle> <Principle>Use Puppeteer if web browsing is required</Principle> <Principle>If any task is unclear, stop and ask for clarification before proceeding</Principle> <Principle>Always take tasks from the top of the GitHub project backlog column at https://github.com/users/Tiberriver256/projects/1 as they are sorted in priority order</Principle> <Principle>Strictly adhere to the WIP limit of 1 - only one issue should be in Research, Implementation, or In Review status at any time</Principle> <Principle>Move issues through the status workflow: Backlog → Research → Implementation → In Review → Done</Principle> <Principle>All work is performed as the GitHub user 'Tiberriver256'. Ensure all issues you work on are assigned to this user.</Principle> </WorkingPrinciples> </AiTaskAgent> ``` -------------------------------------------------------------------------------- /src/features/repositories/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import { isRepositoriesRequest, handleRepositoriesRequest } from './index'; import { getRepository } from './get-repository'; import { getRepositoryDetails } from './get-repository-details'; import { listRepositories } from './list-repositories'; import { getFileContent } from './get-file-content'; import { getAllRepositoriesTree, formatRepositoryTree, } from './get-all-repositories-tree'; import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; // Mock the imported modules jest.mock('./get-repository', () => ({ getRepository: jest.fn(), })); jest.mock('./get-repository-details', () => ({ getRepositoryDetails: jest.fn(), })); jest.mock('./list-repositories', () => ({ listRepositories: jest.fn(), })); jest.mock('./get-file-content', () => ({ getFileContent: jest.fn(), })); jest.mock('./get-all-repositories-tree', () => ({ getAllRepositoriesTree: jest.fn(), formatRepositoryTree: jest.fn(), })); describe('Repositories Request Handlers', () => { const mockConnection = {} as WebApi; describe('isRepositoriesRequest', () => { it('should return true for repositories requests', () => { const validTools = [ 'get_repository', 'get_repository_details', 'list_repositories', 'get_file_content', 'get_all_repositories_tree', ]; validTools.forEach((tool) => { const request = { params: { name: tool, arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isRepositoriesRequest(request)).toBe(true); }); }); it('should return false for non-repositories requests', () => { const request = { params: { name: 'list_projects', arguments: {} }, method: 'tools/call', } as CallToolRequest; expect(isRepositoriesRequest(request)).toBe(false); }); }); describe('handleRepositoriesRequest', () => { it('should handle get_repository request', async () => { const mockRepository = { id: 'repo1', name: 'Repository 1' }; (getRepository as jest.Mock).mockResolvedValue(mockRepository); const request = { params: { name: 'get_repository', arguments: { repositoryId: 'repo1', }, }, method: 'tools/call', } as CallToolRequest; const response = await handleRepositoriesRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockRepository, ); expect(getRepository).toHaveBeenCalledWith( mockConnection, expect.any(String), 'repo1', ); }); it('should handle get_repository_details request', async () => { const mockRepositoryDetails = { repository: { id: 'repo1', name: 'Repository 1' }, statistics: { branches: [] }, refs: { value: [], count: 0 }, }; (getRepositoryDetails as jest.Mock).mockResolvedValue( mockRepositoryDetails, ); const request = { params: { name: 'get_repository_details', arguments: { repositoryId: 'repo1', includeStatistics: true, includeRefs: true, }, }, method: 'tools/call', } as CallToolRequest; const response = await handleRepositoriesRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockRepositoryDetails, ); expect(getRepositoryDetails).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ repositoryId: 'repo1', includeStatistics: true, includeRefs: true, }), ); }); it('should handle list_repositories request', async () => { const mockRepositories = [ { id: 'repo1', name: 'Repository 1' }, { id: 'repo2', name: 'Repository 2' }, ]; (listRepositories as jest.Mock).mockResolvedValue(mockRepositories); const request = { params: { name: 'list_repositories', arguments: { projectId: 'project1', includeLinks: true, }, }, method: 'tools/call', } as CallToolRequest; const response = await handleRepositoriesRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockRepositories, ); expect(listRepositories).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ projectId: 'project1', includeLinks: true, }), ); }); it('should handle get_file_content request', async () => { const mockFileContent = { content: 'file content', isFolder: false }; (getFileContent as jest.Mock).mockResolvedValue(mockFileContent); const request = { params: { name: 'get_file_content', arguments: { repositoryId: 'repo1', path: '/path/to/file', version: 'main', versionType: 'branch', }, }, method: 'tools/call', } as CallToolRequest; const response = await handleRepositoriesRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(JSON.parse(response.content[0].text as string)).toEqual( mockFileContent, ); expect(getFileContent).toHaveBeenCalledWith( mockConnection, expect.any(String), 'repo1', '/path/to/file', { versionType: GitVersionType.Branch, version: 'main' }, ); }); it('should handle get_all_repositories_tree request', async () => { const mockTreeResponse = { repositories: [ { name: 'repo1', tree: [ { name: 'file1', path: '/file1', isFolder: false, level: 0 }, ], stats: { directories: 0, files: 1 }, }, ], }; (getAllRepositoriesTree as jest.Mock).mockResolvedValue(mockTreeResponse); (formatRepositoryTree as jest.Mock).mockReturnValue('repo1\n file1\n'); const request = { params: { name: 'get_all_repositories_tree', arguments: { projectId: 'project1', depth: 2, }, }, method: 'tools/call', } as CallToolRequest; const response = await handleRepositoriesRequest(mockConnection, request); expect(response.content).toHaveLength(1); expect(response.content[0].text as string).toContain('repo1'); expect(getAllRepositoriesTree).toHaveBeenCalledWith( mockConnection, expect.objectContaining({ projectId: 'project1', depth: 2, }), ); expect(formatRepositoryTree).toHaveBeenCalledWith( 'repo1', expect.any(Array), expect.any(Object), undefined, ); }); it('should throw error for unknown tool', async () => { const request = { params: { name: 'unknown_tool', arguments: {}, }, method: 'tools/call', } as CallToolRequest; await expect( handleRepositoriesRequest(mockConnection, request), ).rejects.toThrow('Unknown repositories tool'); }); it('should propagate errors from repository functions', async () => { const mockError = new Error('Test error'); (listRepositories as jest.Mock).mockRejectedValue(mockError); const request = { params: { name: 'list_repositories', arguments: { projectId: 'project1', }, }, method: 'tools/call', } as CallToolRequest; await expect( handleRepositoriesRequest(mockConnection, request), ).rejects.toThrow(mockError); }); }); }); ```