This is page 3 of 8. Use http://codebase.md/tiberriver256/azure-devops-mcp?lines=true&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/work-items/update-work-item/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { updateWorkItem } from './feature'; 3 | import { createWorkItem } from '../create-work-item/feature'; 4 | import { 5 | getTestConnection, 6 | shouldSkipIntegrationTest, 7 | } from '@/shared/test/test-helpers'; 8 | import { CreateWorkItemOptions, UpdateWorkItemOptions } from '../types'; 9 | 10 | describe('updateWorkItem integration', () => { 11 | let connection: WebApi | null = null; 12 | let createdWorkItemId: number | null = null; 13 | 14 | beforeAll(async () => { 15 | // Get a real connection using environment variables 16 | connection = await getTestConnection(); 17 | 18 | // Skip setup if integration tests should be skipped 19 | if (shouldSkipIntegrationTest() || !connection) { 20 | return; 21 | } 22 | 23 | // Create a work item to be used by the update tests 24 | const projectName = 25 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 26 | const uniqueTitle = `Update Test Work Item ${new Date().toISOString()}`; 27 | 28 | const options: CreateWorkItemOptions = { 29 | title: uniqueTitle, 30 | description: 'Initial description for update tests', 31 | priority: 3, 32 | }; 33 | 34 | try { 35 | const workItem = await createWorkItem( 36 | connection, 37 | projectName, 38 | 'Task', 39 | options, 40 | ); 41 | // Ensure the ID is a number 42 | if (workItem && workItem.id !== undefined) { 43 | createdWorkItemId = workItem.id; 44 | } 45 | } catch (error) { 46 | console.error('Failed to create work item for update tests:', error); 47 | } 48 | }); 49 | 50 | test('should update a work item title in Azure DevOps', async () => { 51 | // Skip if no connection is available or if work item wasn't created 52 | if (shouldSkipIntegrationTest() || !connection || !createdWorkItemId) { 53 | return; 54 | } 55 | 56 | // Generate a unique updated title 57 | const updatedTitle = `Updated Title ${new Date().toISOString()}`; 58 | 59 | const options: UpdateWorkItemOptions = { 60 | title: updatedTitle, 61 | }; 62 | 63 | // Act - make an actual API call to Azure DevOps to update the work item 64 | const result = await updateWorkItem(connection, createdWorkItemId, options); 65 | 66 | // Assert on the actual response 67 | expect(result).toBeDefined(); 68 | expect(result.id).toBe(createdWorkItemId); 69 | 70 | // Verify fields match what we updated 71 | expect(result.fields).toBeDefined(); 72 | if (result.fields) { 73 | expect(result.fields['System.Title']).toBe(updatedTitle); 74 | } 75 | }); 76 | 77 | test('should update multiple fields at once', async () => { 78 | // Skip if no connection is available or if work item wasn't created 79 | if (shouldSkipIntegrationTest() || !connection || !createdWorkItemId) { 80 | return; 81 | } 82 | 83 | const newDescription = 84 | 'This is an updated description from integration tests'; 85 | const newPriority = 1; 86 | 87 | const options: UpdateWorkItemOptions = { 88 | description: newDescription, 89 | priority: newPriority, 90 | additionalFields: { 91 | 'System.Tags': 'UpdateTest,Integration', 92 | }, 93 | }; 94 | 95 | // Act - make an actual API call to Azure DevOps 96 | const result = await updateWorkItem(connection, createdWorkItemId, options); 97 | 98 | // Assert on the actual response 99 | expect(result).toBeDefined(); 100 | expect(result.id).toBe(createdWorkItemId); 101 | 102 | // Verify fields match what we updated 103 | expect(result.fields).toBeDefined(); 104 | if (result.fields) { 105 | expect(result.fields['System.Description']).toBe(newDescription); 106 | expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(newPriority); 107 | // Just check that tags contain both values, order may vary 108 | expect(result.fields['System.Tags']).toContain('UpdateTest'); 109 | expect(result.fields['System.Tags']).toContain('Integration'); 110 | } 111 | }); 112 | 113 | test('should throw error when updating non-existent work item', async () => { 114 | // Skip if no connection is available 115 | if (shouldSkipIntegrationTest() || !connection) { 116 | return; 117 | } 118 | 119 | // Use a very large ID that's unlikely to exist 120 | const nonExistentId = 999999999; 121 | 122 | const options: UpdateWorkItemOptions = { 123 | title: 'This should fail', 124 | }; 125 | 126 | // Act & Assert - should throw an error for non-existent work item 127 | await expect( 128 | updateWorkItem(connection, nonExistentId, options), 129 | ).rejects.toThrow(/Failed to update work item|not found/); 130 | }); 131 | }); 132 | ``` -------------------------------------------------------------------------------- /src/features/organizations/list-organizations/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from 'axios'; 2 | import { AzureDevOpsConfig } from '../../../shared/types'; 3 | import { 4 | AzureDevOpsAuthenticationError, 5 | AzureDevOpsError, 6 | } from '../../../shared/errors'; 7 | import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; 8 | import { AuthenticationMethod } from '../../../shared/auth'; 9 | import { Organization, AZURE_DEVOPS_RESOURCE_ID } from '../types'; 10 | 11 | /** 12 | * Lists all Azure DevOps organizations accessible to the authenticated user 13 | * 14 | * Note: This function uses Axios directly rather than the Azure DevOps Node API 15 | * because the WebApi client doesn't support the organizations endpoint. 16 | * 17 | * @param config The Azure DevOps configuration 18 | * @returns Array of organizations 19 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 20 | */ 21 | export async function listOrganizations( 22 | config: AzureDevOpsConfig, 23 | ): Promise<Organization[]> { 24 | try { 25 | // Determine auth method and create appropriate authorization header 26 | let authHeader: string; 27 | 28 | if (config.authMethod === AuthenticationMethod.PersonalAccessToken) { 29 | // PAT authentication 30 | if (!config.personalAccessToken) { 31 | throw new AzureDevOpsAuthenticationError( 32 | 'Personal Access Token (PAT) is required when using PAT authentication', 33 | ); 34 | } 35 | authHeader = createBasicAuthHeader(config.personalAccessToken); 36 | } else { 37 | // Azure Identity authentication (DefaultAzureCredential or AzureCliCredential) 38 | const credential = 39 | config.authMethod === AuthenticationMethod.AzureCli 40 | ? new AzureCliCredential() 41 | : new DefaultAzureCredential(); 42 | 43 | const token = await credential.getToken( 44 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 45 | ); 46 | 47 | if (!token || !token.token) { 48 | throw new AzureDevOpsAuthenticationError( 49 | 'Failed to acquire Azure Identity token', 50 | ); 51 | } 52 | 53 | authHeader = `Bearer ${token.token}`; 54 | } 55 | 56 | // Step 1: Get the user profile to get the publicAlias 57 | const profileResponse = await axios.get( 58 | 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=6.0', 59 | { 60 | headers: { 61 | Authorization: authHeader, 62 | 'Content-Type': 'application/json', 63 | }, 64 | }, 65 | ); 66 | 67 | // Extract the publicAlias 68 | const publicAlias = profileResponse.data.publicAlias; 69 | if (!publicAlias) { 70 | throw new AzureDevOpsAuthenticationError( 71 | 'Unable to get user publicAlias from profile', 72 | ); 73 | } 74 | 75 | // Step 2: Get organizations using the publicAlias 76 | const orgsResponse = await axios.get( 77 | `https://app.vssps.visualstudio.com/_apis/accounts?memberId=${publicAlias}&api-version=6.0`, 78 | { 79 | headers: { 80 | Authorization: authHeader, 81 | 'Content-Type': 'application/json', 82 | }, 83 | }, 84 | ); 85 | 86 | // Define the shape of the API response 87 | interface AzureDevOpsOrganization { 88 | accountId: string; 89 | accountName: string; 90 | accountUri: string; 91 | } 92 | 93 | // Transform the response 94 | return orgsResponse.data.value.map((org: AzureDevOpsOrganization) => ({ 95 | id: org.accountId, 96 | name: org.accountName, 97 | url: org.accountUri, 98 | })); 99 | } catch (error) { 100 | // Handle profile API errors as authentication errors 101 | if (axios.isAxiosError(error) && error.config?.url?.includes('profile')) { 102 | throw new AzureDevOpsAuthenticationError( 103 | `Authentication failed: ${error.toJSON()}`, 104 | ); 105 | } else if ( 106 | error instanceof Error && 107 | (error.message.includes('profile') || 108 | error.message.includes('Unauthorized') || 109 | error.message.includes('Authentication')) 110 | ) { 111 | throw new AzureDevOpsAuthenticationError( 112 | `Authentication failed: ${error.message}`, 113 | ); 114 | } 115 | 116 | if (error instanceof AzureDevOpsError) { 117 | throw error; 118 | } 119 | 120 | throw new AzureDevOpsAuthenticationError( 121 | `Failed to list organizations: ${error instanceof Error ? error.message : String(error)}`, 122 | ); 123 | } 124 | } 125 | 126 | /** 127 | * Creates a Basic Auth header for the Azure DevOps API 128 | * 129 | * @param pat Personal Access Token 130 | * @returns Basic Auth header value 131 | */ 132 | function createBasicAuthHeader(pat: string): string { 133 | const token = Buffer.from(`:${pat}`).toString('base64'); 134 | return `Basic ${token}`; 135 | } 136 | ``` -------------------------------------------------------------------------------- /src/features/work-items/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Re-export schemas and types 2 | export * from './schemas'; 3 | export * from './types'; 4 | 5 | // Re-export features 6 | export * from './list-work-items'; 7 | export * from './get-work-item'; 8 | export * from './create-work-item'; 9 | export * from './update-work-item'; 10 | export * from './manage-work-item-link'; 11 | 12 | // Export tool definitions 13 | export * from './tool-definitions'; 14 | 15 | // New exports for request handling 16 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 17 | import { WebApi } from 'azure-devops-node-api'; 18 | import { 19 | RequestIdentifier, 20 | RequestHandler, 21 | } from '../../shared/types/request-handler'; 22 | import { defaultProject } from '../../utils/environment'; 23 | import { 24 | ListWorkItemsSchema, 25 | GetWorkItemSchema, 26 | CreateWorkItemSchema, 27 | UpdateWorkItemSchema, 28 | ManageWorkItemLinkSchema, 29 | listWorkItems, 30 | getWorkItem, 31 | createWorkItem, 32 | updateWorkItem, 33 | manageWorkItemLink, 34 | } from './'; 35 | 36 | // Define the response type based on observed usage 37 | interface CallToolResponse { 38 | content: Array<{ type: string; text: string }>; 39 | } 40 | 41 | /** 42 | * Checks if the request is for the work items feature 43 | */ 44 | export const isWorkItemsRequest: RequestIdentifier = ( 45 | request: CallToolRequest, 46 | ): boolean => { 47 | const toolName = request.params.name; 48 | return [ 49 | 'get_work_item', 50 | 'list_work_items', 51 | 'create_work_item', 52 | 'update_work_item', 53 | 'manage_work_item_link', 54 | ].includes(toolName); 55 | }; 56 | 57 | /** 58 | * Handles work items feature requests 59 | */ 60 | export const handleWorkItemsRequest: RequestHandler = async ( 61 | connection: WebApi, 62 | request: CallToolRequest, 63 | ): Promise<CallToolResponse> => { 64 | switch (request.params.name) { 65 | case 'get_work_item': { 66 | const args = GetWorkItemSchema.parse(request.params.arguments); 67 | const result = await getWorkItem( 68 | connection, 69 | args.workItemId, 70 | args.expand, 71 | ); 72 | return { 73 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 74 | }; 75 | } 76 | case 'list_work_items': { 77 | const args = ListWorkItemsSchema.parse(request.params.arguments); 78 | const result = await listWorkItems(connection, { 79 | projectId: args.projectId ?? defaultProject, 80 | teamId: args.teamId, 81 | queryId: args.queryId, 82 | wiql: args.wiql, 83 | top: args.top, 84 | skip: args.skip, 85 | }); 86 | return { 87 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 88 | }; 89 | } 90 | case 'create_work_item': { 91 | const args = CreateWorkItemSchema.parse(request.params.arguments); 92 | const result = await createWorkItem( 93 | connection, 94 | args.projectId ?? defaultProject, 95 | args.workItemType, 96 | { 97 | title: args.title, 98 | description: args.description, 99 | assignedTo: args.assignedTo, 100 | areaPath: args.areaPath, 101 | iterationPath: args.iterationPath, 102 | priority: args.priority, 103 | parentId: args.parentId, 104 | additionalFields: args.additionalFields, 105 | }, 106 | ); 107 | return { 108 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 109 | }; 110 | } 111 | case 'update_work_item': { 112 | const args = UpdateWorkItemSchema.parse(request.params.arguments); 113 | const result = await updateWorkItem(connection, args.workItemId, { 114 | title: args.title, 115 | description: args.description, 116 | assignedTo: args.assignedTo, 117 | areaPath: args.areaPath, 118 | iterationPath: args.iterationPath, 119 | priority: args.priority, 120 | state: args.state, 121 | additionalFields: args.additionalFields, 122 | }); 123 | return { 124 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 125 | }; 126 | } 127 | case 'manage_work_item_link': { 128 | const args = ManageWorkItemLinkSchema.parse(request.params.arguments); 129 | const result = await manageWorkItemLink( 130 | connection, 131 | args.projectId ?? defaultProject, 132 | { 133 | sourceWorkItemId: args.sourceWorkItemId, 134 | targetWorkItemId: args.targetWorkItemId, 135 | operation: args.operation, 136 | relationType: args.relationType, 137 | newRelationType: args.newRelationType, 138 | comment: args.comment, 139 | }, 140 | ); 141 | return { 142 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 143 | }; 144 | } 145 | default: 146 | throw new Error(`Unknown work items tool: ${request.params.name}`); 147 | } 148 | }; 149 | ``` -------------------------------------------------------------------------------- /src/features/pipelines/trigger-pipeline/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { triggerPipeline } from './feature'; 3 | import { listPipelines } from '../list-pipelines/feature'; 4 | import { 5 | getTestConnection, 6 | shouldSkipIntegrationTest, 7 | } from '../../../shared/test/test-helpers'; 8 | 9 | describe('triggerPipeline integration', () => { 10 | let connection: WebApi | null = null; 11 | let projectId: string; 12 | let existingPipelineId: number | null = null; 13 | 14 | beforeAll(async () => { 15 | // Get a real connection using environment variables 16 | connection = await getTestConnection(); 17 | 18 | // Get the project ID from environment variables, fallback to default 19 | projectId = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 20 | 21 | // Skip if no connection or project is available 22 | if (shouldSkipIntegrationTest() || !connection || !projectId) { 23 | return; 24 | } 25 | 26 | // Try to get an existing pipeline ID for testing 27 | try { 28 | const pipelines = await listPipelines(connection, { projectId }); 29 | if (pipelines.length > 0) { 30 | existingPipelineId = pipelines[0].id ?? null; 31 | } 32 | } catch (error) { 33 | console.log('Could not find existing pipelines for testing:', error); 34 | } 35 | }); 36 | 37 | test('should trigger a pipeline run', async () => { 38 | // Skip if no connection, project, or pipeline ID is available 39 | if ( 40 | shouldSkipIntegrationTest() || 41 | !connection || 42 | !projectId || 43 | !existingPipelineId 44 | ) { 45 | console.log( 46 | 'Skipping triggerPipeline integration test - no connection, project or existing pipeline available', 47 | ); 48 | return; 49 | } 50 | 51 | // Arrange - prepare options for running the pipeline 52 | const options = { 53 | projectId, 54 | pipelineId: existingPipelineId, 55 | // Use previewRun mode to avoid actually triggering pipelines during tests 56 | previewRun: true, 57 | }; 58 | 59 | // Act - trigger the pipeline 60 | const run = await triggerPipeline(connection, options); 61 | 62 | // Assert - verify the response 63 | expect(run).toBeDefined(); 64 | // Run ID should be present 65 | expect(run.id).toBeDefined(); 66 | expect(typeof run.id).toBe('number'); 67 | // Pipeline reference should match the pipeline we triggered 68 | expect(run.pipeline?.id).toBe(existingPipelineId); 69 | // URL should exist and point to the run 70 | expect(run.url).toBeDefined(); 71 | expect(run.url).toContain('_apis/pipelines'); 72 | }); 73 | 74 | test('should trigger with custom branch', async () => { 75 | // Skip if no connection, project, or pipeline ID is available 76 | if ( 77 | shouldSkipIntegrationTest() || 78 | !connection || 79 | !projectId || 80 | !existingPipelineId 81 | ) { 82 | console.log( 83 | 'Skipping triggerPipeline advanced test - no connection, project or existing pipeline available', 84 | ); 85 | return; 86 | } 87 | 88 | // Arrange - prepare options with a branch 89 | const options = { 90 | projectId, 91 | pipelineId: existingPipelineId, 92 | branch: 'main', // Use the main branch 93 | // Use previewRun mode to avoid actually triggering pipelines during tests 94 | previewRun: true, 95 | }; 96 | 97 | // Act - trigger the pipeline with custom options 98 | const run = await triggerPipeline(connection, options); 99 | 100 | // Assert - verify the response 101 | expect(run).toBeDefined(); 102 | expect(run.id).toBeDefined(); 103 | // Resources should include the specified branch 104 | expect(run.resources?.repositories?.self?.refName).toBe('refs/heads/main'); 105 | }); 106 | 107 | test('should handle non-existent pipeline', async () => { 108 | // Skip if no connection or project is available 109 | if (shouldSkipIntegrationTest() || !connection || !projectId) { 110 | console.log( 111 | 'Skipping triggerPipeline error test - no connection or project available', 112 | ); 113 | return; 114 | } 115 | 116 | // Use a very high ID that is unlikely to exist 117 | const nonExistentPipelineId = 999999; 118 | 119 | try { 120 | // Attempt to trigger a pipeline that shouldn't exist 121 | await triggerPipeline(connection, { 122 | projectId, 123 | pipelineId: nonExistentPipelineId, 124 | }); 125 | // If we reach here without an error, we'll fail the test 126 | fail( 127 | 'Expected triggerPipeline to throw an error for non-existent pipeline', 128 | ); 129 | } catch (error) { 130 | // We expect an error, so this test passes if we get here 131 | expect(error).toBeDefined(); 132 | // Note: the exact error type might vary depending on the API response 133 | } 134 | }); 135 | }); 136 | ``` -------------------------------------------------------------------------------- /docs/tools/core-navigation.md: -------------------------------------------------------------------------------- ```markdown 1 | # Core Navigation Tools for Azure DevOps 2 | 3 | This document provides an overview of the core navigation tools available in the Azure DevOps MCP server. These tools help you discover and navigate the organizational structure of Azure DevOps, from organizations down to repositories. 4 | 5 | ## Navigation Hierarchy 6 | 7 | Azure DevOps resources are organized in a hierarchical structure: 8 | 9 | ``` 10 | Organizations 11 | └── Projects 12 | ├── Repositories 13 | │ └── Branches, Files, etc. 14 | │ └── Pull Requests 15 | └── Work Items 16 | ``` 17 | 18 | The core navigation tools allow you to explore this hierarchy from top to bottom. 19 | 20 | ## Available Tools 21 | 22 | | Tool Name | Description | Required Parameters | Optional Parameters | 23 | | ------------------------------------------------------------- | ----------------------------------------------------------- | ------------------- | ----------------------------------------- | 24 | | [`list_organizations`](./organizations.md#list_organizations) | Lists all Azure DevOps organizations accessible to the user | None | None | 25 | | [`list_projects`](./projects.md#list_projects) | Lists all projects in the organization | None | stateFilter, top, skip, continuationToken | 26 | | [`list_repositories`](./repositories.md#list_repositories) | Lists all repositories in a project | projectId | includeLinks | 27 | | [`list_pull_requests`](./pull-requests.md#list_pull_requests) | Lists pull requests in a repository | projectId, repositoryId | status, creatorId, reviewerId, etc. | 28 | 29 | ## Common Use Cases 30 | 31 | ### Discovering Resource Structure 32 | 33 | A common workflow is to navigate the hierarchy to discover resources: 34 | 35 | 1. Use `list_organizations` to find available organizations 36 | 2. Use `list_projects` to find projects in a selected organization 37 | 3. Use `list_repositories` to find repositories in a selected project 38 | 4. Use `list_pull_requests` to find pull requests in a selected repository 39 | 40 | Example: 41 | 42 | ```typescript 43 | // Step 1: Get all organizations 44 | const organizations = await mcpClient.callTool('list_organizations', {}); 45 | const myOrg = organizations[0]; // Use the first organization for this example 46 | 47 | // Step 2: Get all projects in the organization 48 | const projects = await mcpClient.callTool('list_projects', {}); 49 | const myProject = projects[0]; // Use the first project for this example 50 | 51 | // Step 3: Get all repositories in the project 52 | const repositories = await mcpClient.callTool('list_repositories', { 53 | projectId: myProject.name, 54 | }); 55 | const myRepo = repositories[0]; // Use the first repository for this example 56 | 57 | // Step 4: Get all active pull requests in the repository 58 | const pullRequests = await mcpClient.callTool('list_pull_requests', { 59 | projectId: myProject.name, 60 | repositoryId: myRepo.name, 61 | status: 'active' 62 | }); 63 | ``` 64 | 65 | ### Filtering Projects 66 | 67 | You can filter projects based on their state: 68 | 69 | ```typescript 70 | // Get only well-formed projects (state = 1) 71 | const wellFormedProjects = await mcpClient.callTool('list_projects', { 72 | stateFilter: 1, 73 | }); 74 | ``` 75 | 76 | ### Pagination 77 | 78 | For organizations with many projects or repositories, you can use pagination: 79 | 80 | ```typescript 81 | // Get projects with pagination (first 10 projects) 82 | const firstPage = await mcpClient.callTool('list_projects', { 83 | top: 10, 84 | skip: 0, 85 | }); 86 | 87 | // Get the next 10 projects 88 | const secondPage = await mcpClient.callTool('list_projects', { 89 | top: 10, 90 | skip: 10, 91 | }); 92 | ``` 93 | 94 | ## Detailed Documentation 95 | 96 | For detailed information about each tool, including parameters, response format, and error handling, please refer to the individual tool documentation: 97 | 98 | - [list_organizations](./organizations.md#list_organizations) 99 | - [list_projects](./projects.md#list_projects) 100 | - [list_repositories](./repositories.md#list_repositories) 101 | - [list_pull_requests](./pull-requests.md#list_pull_requests) 102 | - [create_pull_request](./pull-requests.md#create_pull_request) 103 | 104 | ## Error Handling 105 | 106 | Each of these tools may throw various errors, such as authentication errors or permission errors. Be sure to implement proper error handling when using these tools. Refer to the individual tool documentation for specific error types that each tool might throw. 107 | ``` -------------------------------------------------------------------------------- /src/features/work-items/manage-work-item-link/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { manageWorkItemLink } from './feature'; 3 | import { createWorkItem } from '../create-work-item/feature'; 4 | import { 5 | getTestConnection, 6 | shouldSkipIntegrationTest, 7 | } from '../../../shared/test/test-helpers'; 8 | import { CreateWorkItemOptions } from '../types'; 9 | 10 | // Note: These tests will be skipped in CI due to missing credentials 11 | // They are meant to be run manually in a dev environment with proper Azure DevOps setup 12 | describe('manageWorkItemLink integration', () => { 13 | let connection: WebApi | null = null; 14 | let projectName: string; 15 | let sourceWorkItemId: number | null = null; 16 | let targetWorkItemId: number | null = null; 17 | 18 | beforeAll(async () => { 19 | // Get a real connection using environment variables 20 | connection = await getTestConnection(); 21 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 22 | 23 | // Skip setup if integration tests should be skipped 24 | if (shouldSkipIntegrationTest() || !connection) { 25 | return; 26 | } 27 | 28 | try { 29 | // Create source work item for link tests 30 | const sourceOptions: CreateWorkItemOptions = { 31 | title: `Source Work Item for Link Tests ${new Date().toISOString()}`, 32 | description: 33 | 'Source work item for integration tests of manage-work-item-link', 34 | }; 35 | 36 | const sourceWorkItem = await createWorkItem( 37 | connection, 38 | projectName, 39 | 'Task', 40 | sourceOptions, 41 | ); 42 | 43 | // Create target work item for link tests 44 | const targetOptions: CreateWorkItemOptions = { 45 | title: `Target Work Item for Link Tests ${new Date().toISOString()}`, 46 | description: 47 | 'Target work item for integration tests of manage-work-item-link', 48 | }; 49 | 50 | const targetWorkItem = await createWorkItem( 51 | connection, 52 | projectName, 53 | 'Task', 54 | targetOptions, 55 | ); 56 | 57 | // Store the work item IDs for the tests 58 | if (sourceWorkItem && sourceWorkItem.id !== undefined) { 59 | sourceWorkItemId = sourceWorkItem.id; 60 | } 61 | if (targetWorkItem && targetWorkItem.id !== undefined) { 62 | targetWorkItemId = targetWorkItem.id; 63 | } 64 | } catch (error) { 65 | console.error('Failed to create work items for link tests:', error); 66 | } 67 | }); 68 | 69 | test('should add a link between two existing work items', async () => { 70 | // Skip if integration tests should be skipped or if work items weren't created 71 | if ( 72 | shouldSkipIntegrationTest() || 73 | !connection || 74 | !sourceWorkItemId || 75 | !targetWorkItemId 76 | ) { 77 | return; 78 | } 79 | 80 | // Act & Assert - should not throw 81 | const result = await manageWorkItemLink(connection, projectName, { 82 | sourceWorkItemId, 83 | targetWorkItemId, 84 | operation: 'add', 85 | relationType: 'System.LinkTypes.Related', 86 | comment: 'Link created by integration test', 87 | }); 88 | 89 | // Assert 90 | expect(result).toBeDefined(); 91 | expect(result.id).toBe(sourceWorkItemId); 92 | }); 93 | 94 | test('should handle non-existent work items gracefully', async () => { 95 | // Skip if integration tests should be skipped or if no connection 96 | if (shouldSkipIntegrationTest() || !connection) { 97 | return; 98 | } 99 | 100 | // Use a very large ID that's unlikely to exist 101 | const nonExistentId = 999999999; 102 | 103 | // Act & Assert - should throw an error for non-existent work item 104 | await expect( 105 | manageWorkItemLink(connection, projectName, { 106 | sourceWorkItemId: nonExistentId, 107 | targetWorkItemId: nonExistentId, 108 | operation: 'add', 109 | relationType: 'System.LinkTypes.Related', 110 | }), 111 | ).rejects.toThrow(/[Ww]ork [Ii]tem.*not found|does not exist/); 112 | }); 113 | 114 | test('should handle non-existent relationship types gracefully', async () => { 115 | // Skip if integration tests should be skipped or if work items weren't created 116 | if ( 117 | shouldSkipIntegrationTest() || 118 | !connection || 119 | !sourceWorkItemId || 120 | !targetWorkItemId 121 | ) { 122 | return; 123 | } 124 | 125 | // Act & Assert - should throw an error for non-existent relation type 126 | await expect( 127 | manageWorkItemLink(connection, projectName, { 128 | sourceWorkItemId, 129 | targetWorkItemId, 130 | operation: 'add', 131 | relationType: 'NonExistentLinkType', 132 | }), 133 | ).rejects.toThrow(/[Rr]elation|[Ll]ink|[Tt]ype/); // Error may vary, but should mention relation/link/type 134 | }); 135 | }); 136 | ``` -------------------------------------------------------------------------------- /src/features/wikis/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export { getWikis, GetWikisSchema } from './get-wikis'; 2 | export { getWikiPage, GetWikiPageSchema } from './get-wiki-page'; 3 | export { createWiki, CreateWikiSchema, WikiType } from './create-wiki'; 4 | export { updateWikiPage, UpdateWikiPageSchema } from './update-wiki-page'; 5 | export { listWikiPages, ListWikiPagesSchema } from './list-wiki-pages'; 6 | export { createWikiPage, CreateWikiPageSchema } from './create-wiki-page'; 7 | 8 | // Export tool definitions 9 | export * from './tool-definitions'; 10 | 11 | // New exports for request handling 12 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 13 | import { WebApi } from 'azure-devops-node-api'; 14 | import { 15 | RequestIdentifier, 16 | RequestHandler, 17 | } from '../../shared/types/request-handler'; 18 | import { defaultProject, defaultOrg } from '../../utils/environment'; 19 | import { 20 | GetWikisSchema, 21 | GetWikiPageSchema, 22 | CreateWikiSchema, 23 | UpdateWikiPageSchema, 24 | ListWikiPagesSchema, 25 | CreateWikiPageSchema, 26 | getWikis, 27 | getWikiPage, 28 | createWiki, 29 | updateWikiPage, 30 | listWikiPages, 31 | createWikiPage, 32 | } from './'; 33 | 34 | /** 35 | * Checks if the request is for the wikis feature 36 | */ 37 | export const isWikisRequest: RequestIdentifier = ( 38 | request: CallToolRequest, 39 | ): boolean => { 40 | const toolName = request.params.name; 41 | return [ 42 | 'get_wikis', 43 | 'get_wiki_page', 44 | 'create_wiki', 45 | 'update_wiki_page', 46 | 'list_wiki_pages', 47 | 'create_wiki_page', 48 | ].includes(toolName); 49 | }; 50 | 51 | /** 52 | * Handles wikis feature requests 53 | */ 54 | export const handleWikisRequest: RequestHandler = async ( 55 | connection: WebApi, 56 | request: CallToolRequest, 57 | ): Promise<{ content: Array<{ type: string; text: string }> }> => { 58 | switch (request.params.name) { 59 | case 'get_wikis': { 60 | const args = GetWikisSchema.parse(request.params.arguments); 61 | const result = await getWikis(connection, { 62 | organizationId: args.organizationId ?? defaultOrg, 63 | projectId: args.projectId ?? defaultProject, 64 | }); 65 | return { 66 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 67 | }; 68 | } 69 | case 'get_wiki_page': { 70 | const args = GetWikiPageSchema.parse(request.params.arguments); 71 | const result = await getWikiPage({ 72 | organizationId: args.organizationId ?? defaultOrg, 73 | projectId: args.projectId ?? defaultProject, 74 | wikiId: args.wikiId, 75 | pagePath: args.pagePath, 76 | }); 77 | return { 78 | content: [{ type: 'text', text: result }], 79 | }; 80 | } 81 | case 'create_wiki': { 82 | const args = CreateWikiSchema.parse(request.params.arguments); 83 | const result = await createWiki(connection, { 84 | organizationId: args.organizationId ?? defaultOrg, 85 | projectId: args.projectId ?? defaultProject, 86 | name: args.name, 87 | type: args.type, 88 | repositoryId: args.repositoryId ?? undefined, 89 | mappedPath: args.mappedPath ?? undefined, 90 | }); 91 | return { 92 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 93 | }; 94 | } 95 | case 'update_wiki_page': { 96 | const args = UpdateWikiPageSchema.parse(request.params.arguments); 97 | const result = await updateWikiPage({ 98 | organizationId: args.organizationId ?? defaultOrg, 99 | projectId: args.projectId ?? defaultProject, 100 | wikiId: args.wikiId, 101 | pagePath: args.pagePath, 102 | content: args.content, 103 | comment: args.comment, 104 | }); 105 | return { 106 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 107 | }; 108 | } 109 | case 'list_wiki_pages': { 110 | const args = ListWikiPagesSchema.parse(request.params.arguments); 111 | const result = await listWikiPages({ 112 | organizationId: args.organizationId ?? defaultOrg, 113 | projectId: args.projectId ?? defaultProject, 114 | wikiId: args.wikiId, 115 | }); 116 | return { 117 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 118 | }; 119 | } 120 | case 'create_wiki_page': { 121 | const args = CreateWikiPageSchema.parse(request.params.arguments); 122 | const result = await createWikiPage({ 123 | organizationId: args.organizationId ?? defaultOrg, 124 | projectId: args.projectId ?? defaultProject, 125 | wikiId: args.wikiId, 126 | pagePath: args.pagePath, 127 | content: args.content, 128 | comment: args.comment, 129 | }); 130 | return { 131 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 132 | }; 133 | } 134 | default: 135 | throw new Error(`Unknown wikis tool: ${request.params.name}`); 136 | } 137 | }; 138 | ``` -------------------------------------------------------------------------------- /src/features/repositories/get-file-content/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { 3 | GitVersionDescriptor, 4 | GitItem, 5 | GitVersionType, 6 | VersionControlRecursionType, 7 | } from 'azure-devops-node-api/interfaces/GitInterfaces'; 8 | import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; 9 | 10 | /** 11 | * Response format for file content 12 | */ 13 | export interface FileContentResponse { 14 | content: string; 15 | isDirectory: boolean; 16 | } 17 | 18 | /** 19 | * Get content of a file or directory from a repository 20 | * 21 | * @param connection - Azure DevOps WebApi connection 22 | * @param projectId - Project ID or name 23 | * @param repositoryId - Repository ID or name 24 | * @param path - Path to file or directory 25 | * @param versionDescriptor - Optional version descriptor for retrieving file at specific commit/branch/tag 26 | * @returns Content of the file or list of items if path is a directory 27 | */ 28 | export async function getFileContent( 29 | connection: WebApi, 30 | projectId: string, 31 | repositoryId: string, 32 | path: string = '/', 33 | versionDescriptor?: { versionType: GitVersionType; version: string }, 34 | ): Promise<FileContentResponse> { 35 | try { 36 | const gitApi = await connection.getGitApi(); 37 | 38 | // Create version descriptor for API requests 39 | const gitVersionDescriptor: GitVersionDescriptor | undefined = 40 | versionDescriptor 41 | ? { 42 | version: versionDescriptor.version, 43 | versionType: versionDescriptor.versionType, 44 | versionOptions: undefined, 45 | } 46 | : undefined; 47 | 48 | // First, try to get items using the path to determine if it's a directory 49 | let isDirectory = false; 50 | let items: GitItem[] = []; 51 | 52 | try { 53 | items = await gitApi.getItems( 54 | repositoryId, 55 | projectId, 56 | path, 57 | VersionControlRecursionType.OneLevel, 58 | undefined, 59 | undefined, 60 | undefined, 61 | undefined, 62 | gitVersionDescriptor, 63 | ); 64 | 65 | // If multiple items are returned or the path ends with /, it's a directory 66 | isDirectory = items.length > 1 || (path !== '/' && path.endsWith('/')); 67 | } catch { 68 | // If getItems fails, try to get file content directly 69 | isDirectory = false; 70 | } 71 | 72 | if (isDirectory) { 73 | // For directories, return a formatted list of the items 74 | return { 75 | content: JSON.stringify(items, null, 2), 76 | isDirectory: true, 77 | }; 78 | } else { 79 | // For files, get the actual content 80 | try { 81 | // Get file content using the Git API 82 | const contentStream = await gitApi.getItemContent( 83 | repositoryId, 84 | path, 85 | projectId, 86 | undefined, 87 | undefined, 88 | undefined, 89 | undefined, 90 | false, 91 | gitVersionDescriptor, 92 | true, 93 | ); 94 | 95 | // Convert the stream to a string 96 | if (contentStream) { 97 | const chunks: Buffer[] = []; 98 | 99 | // Listen for data events to collect chunks 100 | contentStream.on('data', (chunk) => { 101 | chunks.push(Buffer.from(chunk)); 102 | }); 103 | 104 | // Use a promise to wait for the stream to finish 105 | const content = await new Promise<string>((resolve, reject) => { 106 | contentStream.on('end', () => { 107 | // Concatenate all chunks and convert to string 108 | const buffer = Buffer.concat(chunks); 109 | resolve(buffer.toString('utf8')); 110 | }); 111 | 112 | contentStream.on('error', (err) => { 113 | reject(err); 114 | }); 115 | }); 116 | 117 | return { 118 | content, 119 | isDirectory: false, 120 | }; 121 | } 122 | 123 | throw new Error('No content returned from API'); 124 | } catch (error) { 125 | // If it's a 404 or similar error, throw a ResourceNotFoundError 126 | if ( 127 | error instanceof Error && 128 | (error.message.includes('not found') || 129 | error.message.includes('does not exist')) 130 | ) { 131 | throw new AzureDevOpsResourceNotFoundError( 132 | `Path '${path}' not found in repository '${repositoryId}' of project '${projectId}'`, 133 | ); 134 | } 135 | throw error; 136 | } 137 | } 138 | } catch (error) { 139 | // If it's already an AzureDevOpsResourceNotFoundError, rethrow it 140 | if (error instanceof AzureDevOpsResourceNotFoundError) { 141 | throw error; 142 | } 143 | 144 | // Otherwise, wrap it in a ResourceNotFoundError 145 | throw new AzureDevOpsResourceNotFoundError( 146 | `Failed to get content for path '${path}': ${error instanceof Error ? error.message : String(error)}`, 147 | ); 148 | } 149 | } 150 | ``` -------------------------------------------------------------------------------- /src/features/users/get-me/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios from 'axios'; 3 | import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; 4 | import { 5 | AzureDevOpsError, 6 | AzureDevOpsAuthenticationError, 7 | AzureDevOpsValidationError, 8 | } from '../../../shared/errors'; 9 | import { UserProfile } from '../types'; 10 | 11 | /** 12 | * Get details of the currently authenticated user 13 | * 14 | * This function returns basic profile information about the authenticated user. 15 | * 16 | * @param connection The Azure DevOps WebApi connection 17 | * @returns User profile information including id, displayName, and email 18 | * @throws {AzureDevOpsError} If retrieval of user information fails 19 | */ 20 | export async function getMe(connection: WebApi): Promise<UserProfile> { 21 | try { 22 | // Extract organization from the connection URL 23 | const { organization } = extractOrgFromUrl(connection.serverUrl); 24 | 25 | // Get the authorization header 26 | const authHeader = await getAuthorizationHeader(); 27 | 28 | // Make direct call to the Profile API endpoint 29 | // Note: This API is in the vssps.dev.azure.com domain, not dev.azure.com 30 | const response = await axios.get( 31 | `https://vssps.dev.azure.com/${organization}/_apis/profile/profiles/me?api-version=7.1`, 32 | { 33 | headers: { 34 | Authorization: authHeader, 35 | 'Content-Type': 'application/json', 36 | }, 37 | }, 38 | ); 39 | 40 | const profile = response.data; 41 | 42 | // Return the user profile with required fields 43 | return { 44 | id: profile.id, 45 | displayName: profile.displayName || '', 46 | email: profile.emailAddress || '', 47 | }; 48 | } catch (error) { 49 | // Handle authentication errors 50 | if ( 51 | axios.isAxiosError(error) && 52 | (error.response?.status === 401 || error.response?.status === 403) 53 | ) { 54 | throw new AzureDevOpsAuthenticationError( 55 | `Authentication failed: ${error.message}`, 56 | ); 57 | } 58 | 59 | // If it's already an AzureDevOpsError, rethrow it 60 | if (error instanceof AzureDevOpsError) { 61 | throw error; 62 | } 63 | 64 | // Otherwise, wrap it in a generic error 65 | throw new AzureDevOpsError( 66 | `Failed to get user information: ${error instanceof Error ? error.message : String(error)}`, 67 | ); 68 | } 69 | } 70 | 71 | /** 72 | * Extract organization from the Azure DevOps URL 73 | * 74 | * @param url The Azure DevOps URL 75 | * @returns The organization 76 | */ 77 | function extractOrgFromUrl(url: string): { organization: string } { 78 | // First try modern dev.azure.com format 79 | let match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); 80 | 81 | // If not found, try legacy visualstudio.com format 82 | if (!match) { 83 | match = url.match(/https?:\/\/([^.]+)\.visualstudio\.com/); 84 | } 85 | 86 | // Fallback: capture the first path segment for any URL 87 | if (!match) { 88 | match = url.match(/https?:\/\/[^/]+\/([^/]+)/); 89 | } 90 | 91 | const organization = match ? match[1] : ''; 92 | 93 | if (!organization) { 94 | throw new AzureDevOpsValidationError( 95 | 'Could not extract organization from URL', 96 | ); 97 | } 98 | 99 | return { 100 | organization, 101 | }; 102 | } 103 | 104 | /** 105 | * Get the authorization header for API requests 106 | * 107 | * @returns The authorization header 108 | */ 109 | async function getAuthorizationHeader(): Promise<string> { 110 | try { 111 | // For PAT authentication, we can construct the header directly 112 | if ( 113 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && 114 | process.env.AZURE_DEVOPS_PAT 115 | ) { 116 | // For PAT auth, we can construct the Basic auth header directly 117 | const token = process.env.AZURE_DEVOPS_PAT; 118 | const base64Token = Buffer.from(`:${token}`).toString('base64'); 119 | return `Basic ${base64Token}`; 120 | } 121 | 122 | // For Azure Identity / Azure CLI auth, we need to get a token 123 | // using the Azure DevOps resource ID 124 | // Choose the appropriate credential based on auth method 125 | const credential = 126 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' 127 | ? new AzureCliCredential() 128 | : new DefaultAzureCredential(); 129 | 130 | // Azure DevOps resource ID for token acquisition 131 | const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; 132 | 133 | // Get token for Azure DevOps 134 | const token = await credential.getToken( 135 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 136 | ); 137 | 138 | if (!token || !token.token) { 139 | throw new Error('Failed to acquire token for Azure DevOps'); 140 | } 141 | 142 | return `Bearer ${token.token}`; 143 | } catch (error) { 144 | throw new AzureDevOpsAuthenticationError( 145 | `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, 146 | ); 147 | } 148 | } 149 | ``` -------------------------------------------------------------------------------- /src/features/search/search-wiki/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios from 'axios'; 3 | import { searchWiki } from './feature'; 4 | 5 | // Mock Azure Identity 6 | jest.mock('@azure/identity', () => { 7 | const mockGetToken = jest.fn().mockResolvedValue({ token: 'mock-token' }); 8 | return { 9 | DefaultAzureCredential: jest.fn().mockImplementation(() => ({ 10 | getToken: mockGetToken, 11 | })), 12 | AzureCliCredential: jest.fn().mockImplementation(() => ({ 13 | getToken: mockGetToken, 14 | })), 15 | }; 16 | }); 17 | 18 | // Mock axios 19 | jest.mock('axios'); 20 | const mockedAxios = axios as jest.Mocked<typeof axios>; 21 | 22 | describe('searchWiki unit', () => { 23 | // Mock WebApi connection 24 | const mockConnection = { 25 | _getHttpClient: jest.fn().mockReturnValue({ 26 | getAuthorizationHeader: jest.fn().mockReturnValue('Bearer mock-token'), 27 | }), 28 | getCoreApi: jest.fn().mockImplementation(() => ({ 29 | getProjects: jest 30 | .fn() 31 | .mockResolvedValue([{ name: 'TestProject', id: 'project-id' }]), 32 | })), 33 | serverUrl: 'https://dev.azure.com/testorg', 34 | } as unknown as WebApi; 35 | 36 | beforeEach(() => { 37 | jest.clearAllMocks(); 38 | }); 39 | 40 | test('should return wiki search results with project ID', async () => { 41 | // Arrange 42 | const mockSearchResponse = { 43 | data: { 44 | count: 1, 45 | results: [ 46 | { 47 | fileName: 'Example Page', 48 | path: '/Example Page', 49 | collection: { 50 | name: 'DefaultCollection', 51 | }, 52 | project: { 53 | name: 'TestProject', 54 | id: 'project-id', 55 | }, 56 | hits: [ 57 | { 58 | content: 'This is an example page', 59 | charOffset: 5, 60 | length: 7, 61 | }, 62 | ], 63 | }, 64 | ], 65 | }, 66 | }; 67 | 68 | mockedAxios.post.mockResolvedValueOnce(mockSearchResponse); 69 | 70 | // Act 71 | const result = await searchWiki(mockConnection, { 72 | searchText: 'example', 73 | projectId: 'TestProject', 74 | }); 75 | 76 | // Assert 77 | expect(result).toBeDefined(); 78 | expect(result.count).toBe(1); 79 | expect(result.results).toHaveLength(1); 80 | expect(result.results[0].fileName).toBe('Example Page'); 81 | expect(mockedAxios.post).toHaveBeenCalledTimes(1); 82 | expect(mockedAxios.post).toHaveBeenCalledWith( 83 | expect.stringContaining( 84 | 'https://almsearch.dev.azure.com/testorg/TestProject/_apis/search/wikisearchresults', 85 | ), 86 | expect.objectContaining({ 87 | searchText: 'example', 88 | filters: expect.objectContaining({ 89 | Project: ['TestProject'], 90 | }), 91 | }), 92 | expect.any(Object), 93 | ); 94 | }); 95 | 96 | test('should perform organization-wide wiki search when projectId is not provided', async () => { 97 | // Arrange 98 | const mockSearchResponse = { 99 | data: { 100 | count: 2, 101 | results: [ 102 | { 103 | fileName: 'Example Page 1', 104 | path: '/Example Page 1', 105 | collection: { 106 | name: 'DefaultCollection', 107 | }, 108 | project: { 109 | name: 'Project1', 110 | id: 'project-id-1', 111 | }, 112 | hits: [ 113 | { 114 | content: 'This is an example page', 115 | charOffset: 5, 116 | length: 7, 117 | }, 118 | ], 119 | }, 120 | { 121 | fileName: 'Example Page 2', 122 | path: '/Example Page 2', 123 | collection: { 124 | name: 'DefaultCollection', 125 | }, 126 | project: { 127 | name: 'Project2', 128 | id: 'project-id-2', 129 | }, 130 | hits: [ 131 | { 132 | content: 'This is another example page', 133 | charOffset: 5, 134 | length: 7, 135 | }, 136 | ], 137 | }, 138 | ], 139 | }, 140 | }; 141 | 142 | mockedAxios.post.mockResolvedValueOnce(mockSearchResponse); 143 | 144 | // Act 145 | const result = await searchWiki(mockConnection, { 146 | searchText: 'example', 147 | }); 148 | 149 | // Assert 150 | expect(result).toBeDefined(); 151 | expect(result.count).toBe(2); 152 | expect(result.results).toHaveLength(2); 153 | expect(result.results[0].project.name).toBe('Project1'); 154 | expect(result.results[1].project.name).toBe('Project2'); 155 | expect(mockedAxios.post).toHaveBeenCalledTimes(1); 156 | expect(mockedAxios.post).toHaveBeenCalledWith( 157 | expect.stringContaining( 158 | 'https://almsearch.dev.azure.com/testorg/_apis/search/wikisearchresults', 159 | ), 160 | expect.not.objectContaining({ 161 | filters: expect.objectContaining({ 162 | Project: expect.anything(), 163 | }), 164 | }), 165 | expect.any(Object), 166 | ); 167 | }); 168 | }); 169 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/add-pull-request-comment/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { 3 | Comment, 4 | CommentThreadStatus, 5 | CommentType, 6 | GitPullRequestCommentThread, 7 | } from 'azure-devops-node-api/interfaces/GitInterfaces'; 8 | import { AzureDevOpsError } from '../../../shared/errors'; 9 | import { AddPullRequestCommentOptions, AddCommentResponse } from '../types'; 10 | import { 11 | transformCommentThreadStatus, 12 | transformCommentType, 13 | } from '../../../shared/enums'; 14 | 15 | /** 16 | * Add a comment to a pull request 17 | * 18 | * @param connection The Azure DevOps WebApi connection 19 | * @param projectId The ID or name of the project 20 | * @param repositoryId The ID or name of the repository 21 | * @param pullRequestId The ID of the pull request 22 | * @param options Options for adding the comment 23 | * @returns The created comment or thread 24 | */ 25 | export async function addPullRequestComment( 26 | connection: WebApi, 27 | projectId: string, 28 | repositoryId: string, 29 | pullRequestId: number, 30 | options: AddPullRequestCommentOptions, 31 | ): Promise<AddCommentResponse> { 32 | try { 33 | const gitApi = await connection.getGitApi(); 34 | 35 | // Create comment object 36 | const comment: Comment = { 37 | content: options.content, 38 | commentType: CommentType.Text, // Default to Text type 39 | parentCommentId: options.parentCommentId, 40 | }; 41 | 42 | // Case 1: Add comment to an existing thread 43 | if (options.threadId) { 44 | const createdComment = await gitApi.createComment( 45 | comment, 46 | repositoryId, 47 | pullRequestId, 48 | options.threadId, 49 | projectId, 50 | ); 51 | 52 | if (!createdComment) { 53 | throw new Error('Failed to create pull request comment'); 54 | } 55 | 56 | return { 57 | comment: { 58 | ...createdComment, 59 | commentType: transformCommentType(createdComment.commentType), 60 | }, 61 | }; 62 | } 63 | // Case 2: Create new thread with comment 64 | else { 65 | // Map status string to CommentThreadStatus enum 66 | let threadStatus: CommentThreadStatus | undefined; 67 | if (options.status) { 68 | switch (options.status) { 69 | case 'active': 70 | threadStatus = CommentThreadStatus.Active; 71 | break; 72 | case 'fixed': 73 | threadStatus = CommentThreadStatus.Fixed; 74 | break; 75 | case 'wontFix': 76 | threadStatus = CommentThreadStatus.WontFix; 77 | break; 78 | case 'closed': 79 | threadStatus = CommentThreadStatus.Closed; 80 | break; 81 | case 'pending': 82 | threadStatus = CommentThreadStatus.Pending; 83 | break; 84 | case 'byDesign': 85 | threadStatus = CommentThreadStatus.ByDesign; 86 | break; 87 | case 'unknown': 88 | threadStatus = CommentThreadStatus.Unknown; 89 | break; 90 | } 91 | } 92 | 93 | // Create thread with comment 94 | const thread: GitPullRequestCommentThread = { 95 | comments: [comment], 96 | status: threadStatus, 97 | }; 98 | 99 | // Add file context if specified (file comment) 100 | if (options.filePath) { 101 | thread.threadContext = { 102 | filePath: options.filePath, 103 | // Only add line information if provided 104 | rightFileStart: options.lineNumber 105 | ? { 106 | line: options.lineNumber, 107 | offset: 1, // Default to start of line 108 | } 109 | : undefined, 110 | rightFileEnd: options.lineNumber 111 | ? { 112 | line: options.lineNumber, 113 | offset: 1, // Default to start of line 114 | } 115 | : undefined, 116 | }; 117 | } 118 | 119 | const createdThread = await gitApi.createThread( 120 | thread, 121 | repositoryId, 122 | pullRequestId, 123 | projectId, 124 | ); 125 | 126 | if ( 127 | !createdThread || 128 | !createdThread.comments || 129 | createdThread.comments.length === 0 130 | ) { 131 | throw new Error('Failed to create pull request comment thread'); 132 | } 133 | 134 | return { 135 | comment: { 136 | ...createdThread.comments[0], 137 | commentType: transformCommentType( 138 | createdThread.comments[0].commentType, 139 | ), 140 | }, 141 | thread: { 142 | ...createdThread, 143 | status: transformCommentThreadStatus(createdThread.status), 144 | comments: createdThread.comments?.map((comment) => ({ 145 | ...comment, 146 | commentType: transformCommentType(comment.commentType), 147 | })), 148 | }, 149 | }; 150 | } 151 | } catch (error) { 152 | if (error instanceof AzureDevOpsError) { 153 | throw error; 154 | } 155 | throw new Error( 156 | `Failed to add pull request comment: ${error instanceof Error ? error.message : String(error)}`, 157 | ); 158 | } 159 | } 160 | ``` -------------------------------------------------------------------------------- /src/features/work-items/manage-work-item-link/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { manageWorkItemLink } from './feature'; 2 | import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; 3 | 4 | describe('manageWorkItemLink', () => { 5 | let mockConnection: any; 6 | let mockWitApi: any; 7 | 8 | const projectId = 'test-project'; 9 | const sourceWorkItemId = 123; 10 | const targetWorkItemId = 456; 11 | const relationType = 'System.LinkTypes.Related'; 12 | const newRelationType = 'System.LinkTypes.Hierarchy-Forward'; 13 | const comment = 'Test link comment'; 14 | 15 | beforeEach(() => { 16 | mockWitApi = { 17 | updateWorkItem: jest.fn(), 18 | }; 19 | 20 | mockConnection = { 21 | getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWitApi), 22 | serverUrl: 'https://dev.azure.com/test-org', 23 | }; 24 | }); 25 | 26 | test('should add a work item link', async () => { 27 | // Setup 28 | const updatedWorkItem = { 29 | id: sourceWorkItemId, 30 | fields: { 'System.Title': 'Test' }, 31 | }; 32 | mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem); 33 | 34 | // Execute 35 | const result = await manageWorkItemLink(mockConnection, projectId, { 36 | sourceWorkItemId, 37 | targetWorkItemId, 38 | operation: 'add', 39 | relationType, 40 | comment, 41 | }); 42 | 43 | // Verify 44 | expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); 45 | expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( 46 | {}, // customHeaders 47 | [ 48 | { 49 | op: 'add', 50 | path: '/relations/-', 51 | value: { 52 | rel: relationType, 53 | url: `${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`, 54 | attributes: { comment }, 55 | }, 56 | }, 57 | ], 58 | sourceWorkItemId, 59 | projectId, 60 | ); 61 | expect(result).toEqual(updatedWorkItem); 62 | }); 63 | 64 | test('should remove a work item link', async () => { 65 | // Setup 66 | const updatedWorkItem = { 67 | id: sourceWorkItemId, 68 | fields: { 'System.Title': 'Test' }, 69 | }; 70 | mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem); 71 | 72 | // Execute 73 | const result = await manageWorkItemLink(mockConnection, projectId, { 74 | sourceWorkItemId, 75 | targetWorkItemId, 76 | operation: 'remove', 77 | relationType, 78 | }); 79 | 80 | // Verify 81 | expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); 82 | expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( 83 | {}, // customHeaders 84 | [ 85 | { 86 | op: 'remove', 87 | path: `/relations/+[rel=${relationType};url=${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}]`, 88 | }, 89 | ], 90 | sourceWorkItemId, 91 | projectId, 92 | ); 93 | expect(result).toEqual(updatedWorkItem); 94 | }); 95 | 96 | test('should update a work item link', async () => { 97 | // Setup 98 | const updatedWorkItem = { 99 | id: sourceWorkItemId, 100 | fields: { 'System.Title': 'Test' }, 101 | }; 102 | mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem); 103 | 104 | // Execute 105 | const result = await manageWorkItemLink(mockConnection, projectId, { 106 | sourceWorkItemId, 107 | targetWorkItemId, 108 | operation: 'update', 109 | relationType, 110 | newRelationType, 111 | comment, 112 | }); 113 | 114 | // Verify 115 | expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); 116 | expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( 117 | {}, // customHeaders 118 | [ 119 | { 120 | op: 'remove', 121 | path: `/relations/+[rel=${relationType};url=${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}]`, 122 | }, 123 | { 124 | op: 'add', 125 | path: '/relations/-', 126 | value: { 127 | rel: newRelationType, 128 | url: `${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`, 129 | attributes: { comment }, 130 | }, 131 | }, 132 | ], 133 | sourceWorkItemId, 134 | projectId, 135 | ); 136 | expect(result).toEqual(updatedWorkItem); 137 | }); 138 | 139 | test('should throw error when work item not found', async () => { 140 | // Setup 141 | mockWitApi.updateWorkItem.mockResolvedValue(null); 142 | 143 | // Execute and verify 144 | await expect( 145 | manageWorkItemLink(mockConnection, projectId, { 146 | sourceWorkItemId, 147 | targetWorkItemId, 148 | operation: 'add', 149 | relationType, 150 | }), 151 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 152 | }); 153 | 154 | test('should throw error when update operation missing newRelationType', async () => { 155 | // Execute and verify 156 | await expect( 157 | manageWorkItemLink(mockConnection, projectId, { 158 | sourceWorkItemId, 159 | targetWorkItemId, 160 | operation: 'update', 161 | relationType, 162 | // newRelationType is missing 163 | }), 164 | ).rejects.toThrow('New relation type is required for update operation'); 165 | }); 166 | }); 167 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export * from './schemas'; 2 | export * from './types'; 3 | export * from './create-pull-request'; 4 | export * from './list-pull-requests'; 5 | export * from './get-pull-request-comments'; 6 | export * from './add-pull-request-comment'; 7 | export * from './update-pull-request'; 8 | 9 | // Export tool definitions 10 | export * from './tool-definitions'; 11 | 12 | // New exports for request handling 13 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 14 | import { WebApi } from 'azure-devops-node-api'; 15 | import { 16 | RequestIdentifier, 17 | RequestHandler, 18 | } from '../../shared/types/request-handler'; 19 | import { defaultProject } from '../../utils/environment'; 20 | import { 21 | CreatePullRequestSchema, 22 | ListPullRequestsSchema, 23 | GetPullRequestCommentsSchema, 24 | AddPullRequestCommentSchema, 25 | UpdatePullRequestSchema, 26 | createPullRequest, 27 | listPullRequests, 28 | getPullRequestComments, 29 | addPullRequestComment, 30 | updatePullRequest, 31 | } from './'; 32 | 33 | /** 34 | * Checks if the request is for the pull requests feature 35 | */ 36 | export const isPullRequestsRequest: RequestIdentifier = ( 37 | request: CallToolRequest, 38 | ): boolean => { 39 | const toolName = request.params.name; 40 | return [ 41 | 'create_pull_request', 42 | 'list_pull_requests', 43 | 'get_pull_request_comments', 44 | 'add_pull_request_comment', 45 | 'update_pull_request', 46 | ].includes(toolName); 47 | }; 48 | 49 | /** 50 | * Handles pull requests feature requests 51 | */ 52 | export const handlePullRequestsRequest: RequestHandler = async ( 53 | connection: WebApi, 54 | request: CallToolRequest, 55 | ): Promise<{ content: Array<{ type: string; text: string }> }> => { 56 | switch (request.params.name) { 57 | case 'create_pull_request': { 58 | const args = CreatePullRequestSchema.parse(request.params.arguments); 59 | const result = await createPullRequest( 60 | connection, 61 | args.projectId ?? defaultProject, 62 | args.repositoryId, 63 | args, 64 | ); 65 | return { 66 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 67 | }; 68 | } 69 | case 'list_pull_requests': { 70 | const params = ListPullRequestsSchema.parse(request.params.arguments); 71 | const result = await listPullRequests( 72 | connection, 73 | params.projectId ?? defaultProject, 74 | params.repositoryId, 75 | { 76 | projectId: params.projectId ?? defaultProject, 77 | repositoryId: params.repositoryId, 78 | status: params.status, 79 | creatorId: params.creatorId, 80 | reviewerId: params.reviewerId, 81 | sourceRefName: params.sourceRefName, 82 | targetRefName: params.targetRefName, 83 | top: params.top, 84 | skip: params.skip, 85 | }, 86 | ); 87 | return { 88 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 89 | }; 90 | } 91 | case 'get_pull_request_comments': { 92 | const params = GetPullRequestCommentsSchema.parse( 93 | request.params.arguments, 94 | ); 95 | const result = await getPullRequestComments( 96 | connection, 97 | params.projectId ?? defaultProject, 98 | params.repositoryId, 99 | params.pullRequestId, 100 | { 101 | projectId: params.projectId ?? defaultProject, 102 | repositoryId: params.repositoryId, 103 | pullRequestId: params.pullRequestId, 104 | threadId: params.threadId, 105 | includeDeleted: params.includeDeleted, 106 | top: params.top, 107 | }, 108 | ); 109 | return { 110 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 111 | }; 112 | } 113 | case 'add_pull_request_comment': { 114 | const params = AddPullRequestCommentSchema.parse( 115 | request.params.arguments, 116 | ); 117 | const result = await addPullRequestComment( 118 | connection, 119 | params.projectId ?? defaultProject, 120 | params.repositoryId, 121 | params.pullRequestId, 122 | { 123 | projectId: params.projectId ?? defaultProject, 124 | repositoryId: params.repositoryId, 125 | pullRequestId: params.pullRequestId, 126 | content: params.content, 127 | threadId: params.threadId, 128 | parentCommentId: params.parentCommentId, 129 | filePath: params.filePath, 130 | lineNumber: params.lineNumber, 131 | status: params.status, 132 | }, 133 | ); 134 | return { 135 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 136 | }; 137 | } 138 | case 'update_pull_request': { 139 | const params = UpdatePullRequestSchema.parse(request.params.arguments); 140 | const fixedParams = { 141 | ...params, 142 | projectId: params.projectId ?? defaultProject, 143 | }; 144 | const result = await updatePullRequest(fixedParams); 145 | return { 146 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 147 | }; 148 | } 149 | default: 150 | throw new Error(`Unknown pull requests tool: ${request.params.name}`); 151 | } 152 | }; 153 | ``` -------------------------------------------------------------------------------- /src/features/pipelines/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { isPipelinesRequest, handlePipelinesRequest } from './index'; 4 | import { listPipelines } from './list-pipelines/feature'; 5 | import { getPipeline } from './get-pipeline/feature'; 6 | import { triggerPipeline } from './trigger-pipeline/feature'; 7 | 8 | jest.mock('./list-pipelines/feature'); 9 | jest.mock('./get-pipeline/feature'); 10 | jest.mock('./trigger-pipeline/feature'); 11 | 12 | describe('Pipelines Request Handlers', () => { 13 | const mockConnection = {} as WebApi; 14 | 15 | describe('isPipelinesRequest', () => { 16 | it('should return true for pipelines requests', () => { 17 | const validTools = ['list_pipelines', 'get_pipeline', 'trigger_pipeline']; 18 | validTools.forEach((tool) => { 19 | const request = { 20 | params: { name: tool, arguments: {} }, 21 | method: 'tools/call', 22 | } as CallToolRequest; 23 | expect(isPipelinesRequest(request)).toBe(true); 24 | }); 25 | }); 26 | 27 | it('should return false for non-pipelines requests', () => { 28 | const request = { 29 | params: { name: 'get_project', arguments: {} }, 30 | method: 'tools/call', 31 | } as CallToolRequest; 32 | expect(isPipelinesRequest(request)).toBe(false); 33 | }); 34 | }); 35 | 36 | describe('handlePipelinesRequest', () => { 37 | it('should handle list_pipelines request', async () => { 38 | const mockPipelines = [ 39 | { id: 1, name: 'Pipeline 1' }, 40 | { id: 2, name: 'Pipeline 2' }, 41 | ]; 42 | 43 | (listPipelines as jest.Mock).mockResolvedValue(mockPipelines); 44 | 45 | const request = { 46 | params: { 47 | name: 'list_pipelines', 48 | arguments: { 49 | projectId: 'test-project', 50 | }, 51 | }, 52 | method: 'tools/call', 53 | } as CallToolRequest; 54 | 55 | const response = await handlePipelinesRequest(mockConnection, request); 56 | expect(response.content).toHaveLength(1); 57 | expect(JSON.parse(response.content[0].text as string)).toEqual( 58 | mockPipelines, 59 | ); 60 | expect(listPipelines).toHaveBeenCalledWith( 61 | mockConnection, 62 | expect.objectContaining({ 63 | projectId: 'test-project', 64 | }), 65 | ); 66 | }); 67 | 68 | it('should handle get_pipeline request', async () => { 69 | const mockPipeline = { id: 1, name: 'Pipeline 1' }; 70 | (getPipeline as jest.Mock).mockResolvedValue(mockPipeline); 71 | 72 | const request = { 73 | params: { 74 | name: 'get_pipeline', 75 | arguments: { 76 | projectId: 'test-project', 77 | pipelineId: 1, 78 | }, 79 | }, 80 | method: 'tools/call', 81 | } as CallToolRequest; 82 | 83 | const response = await handlePipelinesRequest(mockConnection, request); 84 | expect(response.content).toHaveLength(1); 85 | expect(JSON.parse(response.content[0].text as string)).toEqual( 86 | mockPipeline, 87 | ); 88 | expect(getPipeline).toHaveBeenCalledWith( 89 | mockConnection, 90 | expect.objectContaining({ 91 | projectId: 'test-project', 92 | pipelineId: 1, 93 | }), 94 | ); 95 | }); 96 | 97 | it('should handle trigger_pipeline request', async () => { 98 | const mockRun = { id: 1, state: 'inProgress' }; 99 | (triggerPipeline as jest.Mock).mockResolvedValue(mockRun); 100 | 101 | const request = { 102 | params: { 103 | name: 'trigger_pipeline', 104 | arguments: { 105 | projectId: 'test-project', 106 | pipelineId: 1, 107 | }, 108 | }, 109 | method: 'tools/call', 110 | } as CallToolRequest; 111 | 112 | const response = await handlePipelinesRequest(mockConnection, request); 113 | expect(response.content).toHaveLength(1); 114 | expect(JSON.parse(response.content[0].text as string)).toEqual(mockRun); 115 | expect(triggerPipeline).toHaveBeenCalledWith( 116 | mockConnection, 117 | expect.objectContaining({ 118 | projectId: 'test-project', 119 | pipelineId: 1, 120 | }), 121 | ); 122 | }); 123 | 124 | it('should throw error for unknown tool', async () => { 125 | const request = { 126 | params: { 127 | name: 'unknown_tool', 128 | arguments: {}, 129 | }, 130 | method: 'tools/call', 131 | } as CallToolRequest; 132 | 133 | await expect( 134 | handlePipelinesRequest(mockConnection, request), 135 | ).rejects.toThrow('Unknown pipelines tool'); 136 | }); 137 | 138 | it('should propagate errors from pipeline functions', async () => { 139 | const mockError = new Error('Test error'); 140 | (listPipelines as jest.Mock).mockRejectedValue(mockError); 141 | 142 | const request = { 143 | params: { 144 | name: 'list_pipelines', 145 | arguments: { 146 | projectId: 'test-project', 147 | }, 148 | }, 149 | method: 'tools/call', 150 | } as CallToolRequest; 151 | 152 | await expect( 153 | handlePipelinesRequest(mockConnection, request), 154 | ).rejects.toThrow(mockError); 155 | }); 156 | }); 157 | }); 158 | ``` -------------------------------------------------------------------------------- /src/features/projects/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { isProjectsRequest, handleProjectsRequest } from './index'; 4 | import { getProject } from './get-project'; 5 | import { getProjectDetails } from './get-project-details'; 6 | import { listProjects } from './list-projects'; 7 | 8 | // Mock the imported modules 9 | jest.mock('./get-project', () => ({ 10 | getProject: jest.fn(), 11 | })); 12 | 13 | jest.mock('./get-project-details', () => ({ 14 | getProjectDetails: jest.fn(), 15 | })); 16 | 17 | jest.mock('./list-projects', () => ({ 18 | listProjects: jest.fn(), 19 | })); 20 | 21 | describe('Projects Request Handlers', () => { 22 | const mockConnection = {} as WebApi; 23 | 24 | describe('isProjectsRequest', () => { 25 | it('should return true for projects requests', () => { 26 | const validTools = [ 27 | 'list_projects', 28 | 'get_project', 29 | 'get_project_details', 30 | ]; 31 | validTools.forEach((tool) => { 32 | const request = { 33 | params: { name: tool, arguments: {} }, 34 | method: 'tools/call', 35 | } as CallToolRequest; 36 | expect(isProjectsRequest(request)).toBe(true); 37 | }); 38 | }); 39 | 40 | it('should return false for non-projects requests', () => { 41 | const request = { 42 | params: { name: 'list_work_items', arguments: {} }, 43 | method: 'tools/call', 44 | } as CallToolRequest; 45 | expect(isProjectsRequest(request)).toBe(false); 46 | }); 47 | }); 48 | 49 | describe('handleProjectsRequest', () => { 50 | it('should handle list_projects request', async () => { 51 | const mockProjects = [ 52 | { id: '1', name: 'Project 1' }, 53 | { id: '2', name: 'Project 2' }, 54 | ]; 55 | 56 | (listProjects as jest.Mock).mockResolvedValue(mockProjects); 57 | 58 | const request = { 59 | params: { 60 | name: 'list_projects', 61 | arguments: { 62 | top: 10, 63 | }, 64 | }, 65 | method: 'tools/call', 66 | } as CallToolRequest; 67 | 68 | const response = await handleProjectsRequest(mockConnection, request); 69 | expect(response.content).toHaveLength(1); 70 | expect(JSON.parse(response.content[0].text as string)).toEqual( 71 | mockProjects, 72 | ); 73 | expect(listProjects).toHaveBeenCalledWith( 74 | mockConnection, 75 | expect.objectContaining({ 76 | top: 10, 77 | }), 78 | ); 79 | }); 80 | 81 | it('should handle get_project request', async () => { 82 | const mockProject = { id: '1', name: 'Project 1' }; 83 | (getProject as jest.Mock).mockResolvedValue(mockProject); 84 | 85 | const request = { 86 | params: { 87 | name: 'get_project', 88 | arguments: { 89 | projectId: 'Project 1', 90 | }, 91 | }, 92 | method: 'tools/call', 93 | } as CallToolRequest; 94 | 95 | const response = await handleProjectsRequest(mockConnection, request); 96 | expect(response.content).toHaveLength(1); 97 | expect(JSON.parse(response.content[0].text as string)).toEqual( 98 | mockProject, 99 | ); 100 | expect(getProject).toHaveBeenCalledWith(mockConnection, 'Project 1'); 101 | }); 102 | 103 | it('should handle get_project_details request', async () => { 104 | const mockProjectDetails = { 105 | id: '1', 106 | name: 'Project 1', 107 | teams: [{ id: 'team1', name: 'Team 1' }], 108 | }; 109 | 110 | (getProjectDetails as jest.Mock).mockResolvedValue(mockProjectDetails); 111 | 112 | const request = { 113 | params: { 114 | name: 'get_project_details', 115 | arguments: { 116 | projectId: 'Project 1', 117 | includeTeams: true, 118 | }, 119 | }, 120 | method: 'tools/call', 121 | } as CallToolRequest; 122 | 123 | const response = await handleProjectsRequest(mockConnection, request); 124 | expect(response.content).toHaveLength(1); 125 | expect(JSON.parse(response.content[0].text as string)).toEqual( 126 | mockProjectDetails, 127 | ); 128 | expect(getProjectDetails).toHaveBeenCalledWith( 129 | mockConnection, 130 | expect.objectContaining({ 131 | projectId: 'Project 1', 132 | includeTeams: true, 133 | }), 134 | ); 135 | }); 136 | 137 | it('should throw error for unknown tool', async () => { 138 | const request = { 139 | params: { 140 | name: 'unknown_tool', 141 | arguments: {}, 142 | }, 143 | method: 'tools/call', 144 | } as CallToolRequest; 145 | 146 | await expect( 147 | handleProjectsRequest(mockConnection, request), 148 | ).rejects.toThrow('Unknown projects tool'); 149 | }); 150 | 151 | it('should propagate errors from project functions', async () => { 152 | const mockError = new Error('Test error'); 153 | (listProjects as jest.Mock).mockRejectedValue(mockError); 154 | 155 | const request = { 156 | params: { 157 | name: 'list_projects', 158 | arguments: {}, 159 | }, 160 | method: 'tools/call', 161 | } as CallToolRequest; 162 | 163 | await expect( 164 | handleProjectsRequest(mockConnection, request), 165 | ).rejects.toThrow(mockError); 166 | }); 167 | }); 168 | }); 169 | ``` -------------------------------------------------------------------------------- /src/features/repositories/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Re-export schemas and types 2 | export * from './schemas'; 3 | export * from './types'; 4 | 5 | // Re-export features 6 | export * from './get-repository'; 7 | export * from './get-repository-details'; 8 | export * from './list-repositories'; 9 | export * from './get-file-content'; 10 | export * from './get-all-repositories-tree'; 11 | 12 | // Export tool definitions 13 | export * from './tool-definitions'; 14 | 15 | // New exports for request handling 16 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 17 | import { WebApi } from 'azure-devops-node-api'; 18 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; 19 | import { 20 | RequestIdentifier, 21 | RequestHandler, 22 | } from '../../shared/types/request-handler'; 23 | import { defaultProject, defaultOrg } from '../../utils/environment'; 24 | import { 25 | GetRepositorySchema, 26 | GetRepositoryDetailsSchema, 27 | ListRepositoriesSchema, 28 | GetFileContentSchema, 29 | GetAllRepositoriesTreeSchema, 30 | getRepository, 31 | getRepositoryDetails, 32 | listRepositories, 33 | getFileContent, 34 | getAllRepositoriesTree, 35 | formatRepositoryTree, 36 | } from './'; 37 | 38 | /** 39 | * Checks if the request is for the repositories feature 40 | */ 41 | export const isRepositoriesRequest: RequestIdentifier = ( 42 | request: CallToolRequest, 43 | ): boolean => { 44 | const toolName = request.params.name; 45 | return [ 46 | 'get_repository', 47 | 'get_repository_details', 48 | 'list_repositories', 49 | 'get_file_content', 50 | 'get_all_repositories_tree', 51 | ].includes(toolName); 52 | }; 53 | 54 | /** 55 | * Handles repositories feature requests 56 | */ 57 | export const handleRepositoriesRequest: RequestHandler = async ( 58 | connection: WebApi, 59 | request: CallToolRequest, 60 | ): Promise<{ content: Array<{ type: string; text: string }> }> => { 61 | switch (request.params.name) { 62 | case 'get_repository': { 63 | const args = GetRepositorySchema.parse(request.params.arguments); 64 | const result = await getRepository( 65 | connection, 66 | args.projectId ?? defaultProject, 67 | args.repositoryId, 68 | ); 69 | return { 70 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 71 | }; 72 | } 73 | case 'get_repository_details': { 74 | const args = GetRepositoryDetailsSchema.parse(request.params.arguments); 75 | const result = await getRepositoryDetails(connection, { 76 | projectId: args.projectId ?? defaultProject, 77 | repositoryId: args.repositoryId, 78 | includeStatistics: args.includeStatistics, 79 | includeRefs: args.includeRefs, 80 | refFilter: args.refFilter, 81 | branchName: args.branchName, 82 | }); 83 | return { 84 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 85 | }; 86 | } 87 | case 'list_repositories': { 88 | const args = ListRepositoriesSchema.parse(request.params.arguments); 89 | const result = await listRepositories(connection, { 90 | ...args, 91 | projectId: args.projectId ?? defaultProject, 92 | }); 93 | return { 94 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 95 | }; 96 | } 97 | case 'get_file_content': { 98 | const args = GetFileContentSchema.parse(request.params.arguments); 99 | 100 | // Map the string version type to the GitVersionType enum 101 | let versionTypeEnum: GitVersionType | undefined; 102 | if (args.versionType && args.version) { 103 | if (args.versionType === 'branch') { 104 | versionTypeEnum = GitVersionType.Branch; 105 | } else if (args.versionType === 'commit') { 106 | versionTypeEnum = GitVersionType.Commit; 107 | } else if (args.versionType === 'tag') { 108 | versionTypeEnum = GitVersionType.Tag; 109 | } 110 | } 111 | 112 | const result = await getFileContent( 113 | connection, 114 | args.projectId ?? defaultProject, 115 | args.repositoryId, 116 | args.path, 117 | versionTypeEnum !== undefined && args.version 118 | ? { versionType: versionTypeEnum, version: args.version } 119 | : undefined, 120 | ); 121 | return { 122 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 123 | }; 124 | } 125 | case 'get_all_repositories_tree': { 126 | const args = GetAllRepositoriesTreeSchema.parse(request.params.arguments); 127 | const result = await getAllRepositoriesTree(connection, { 128 | ...args, 129 | projectId: args.projectId ?? defaultProject, 130 | organizationId: args.organizationId ?? defaultOrg, 131 | }); 132 | 133 | // Format the output as plain text tree representation 134 | let formattedOutput = ''; 135 | for (const repo of result.repositories) { 136 | formattedOutput += formatRepositoryTree( 137 | repo.name, 138 | repo.tree, 139 | repo.stats, 140 | repo.error, 141 | ); 142 | formattedOutput += '\n'; // Add blank line between repositories 143 | } 144 | 145 | return { 146 | content: [{ type: 'text', text: formattedOutput }], 147 | }; 148 | } 149 | default: 150 | throw new Error(`Unknown repositories tool: ${request.params.name}`); 151 | } 152 | }; 153 | ``` -------------------------------------------------------------------------------- /src/shared/errors/azure-devops-errors.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Base error class for Azure DevOps API errors. 3 | * All specific Azure DevOps errors should extend this class. 4 | * 5 | * @class AzureDevOpsError 6 | * @extends {Error} 7 | */ 8 | export class AzureDevOpsError extends Error { 9 | constructor(message: string, options?: ErrorOptions) { 10 | super(message, options); 11 | this.name = 'AzureDevOpsError'; 12 | } 13 | } 14 | 15 | /** 16 | * Error thrown when authentication with Azure DevOps fails. 17 | * This can occur due to invalid credentials, expired tokens, or network issues. 18 | * 19 | * @class AzureDevOpsAuthenticationError 20 | * @extends {AzureDevOpsError} 21 | */ 22 | export class AzureDevOpsAuthenticationError extends AzureDevOpsError { 23 | constructor(message: string, options?: ErrorOptions) { 24 | super(message, options); 25 | this.name = 'AzureDevOpsAuthenticationError'; 26 | } 27 | } 28 | 29 | /** 30 | * Type for API response error details 31 | */ 32 | export type ApiErrorResponse = { 33 | message?: string; 34 | statusCode?: number; 35 | details?: unknown; 36 | [key: string]: unknown; 37 | }; 38 | 39 | /** 40 | * Error thrown when input validation fails. 41 | * This includes invalid parameters, malformed requests, or missing required fields. 42 | * 43 | * @class AzureDevOpsValidationError 44 | * @extends {AzureDevOpsError} 45 | * @property {ApiErrorResponse} [response] - The raw response from the API containing validation details 46 | */ 47 | export class AzureDevOpsValidationError extends AzureDevOpsError { 48 | response?: ApiErrorResponse; 49 | 50 | constructor( 51 | message: string, 52 | response?: ApiErrorResponse, 53 | options?: ErrorOptions, 54 | ) { 55 | super(message, options); 56 | this.name = 'AzureDevOpsValidationError'; 57 | this.response = response; 58 | } 59 | } 60 | 61 | /** 62 | * Error thrown when a requested resource is not found. 63 | * This can occur when trying to access non-existent projects, repositories, or work items. 64 | * 65 | * @class AzureDevOpsResourceNotFoundError 66 | * @extends {AzureDevOpsError} 67 | */ 68 | export class AzureDevOpsResourceNotFoundError extends AzureDevOpsError { 69 | constructor(message: string, options?: ErrorOptions) { 70 | super(message, options); 71 | this.name = 'AzureDevOpsResourceNotFoundError'; 72 | } 73 | } 74 | 75 | /** 76 | * Error thrown when the user lacks permissions for an operation. 77 | * This occurs when trying to access or modify resources without proper authorization. 78 | * 79 | * @class AzureDevOpsPermissionError 80 | * @extends {AzureDevOpsError} 81 | */ 82 | export class AzureDevOpsPermissionError extends AzureDevOpsError { 83 | constructor(message: string, options?: ErrorOptions) { 84 | super(message, options); 85 | this.name = 'AzureDevOpsPermissionError'; 86 | } 87 | } 88 | 89 | /** 90 | * Error thrown when the API rate limit is exceeded. 91 | * Contains information about when the rate limit will reset. 92 | * 93 | * @class AzureDevOpsRateLimitError 94 | * @extends {AzureDevOpsError} 95 | * @property {Date} resetAt - The time when the rate limit will reset 96 | */ 97 | export class AzureDevOpsRateLimitError extends AzureDevOpsError { 98 | resetAt: Date; 99 | 100 | constructor(message: string, resetAt: Date, options?: ErrorOptions) { 101 | super(message, options); 102 | this.name = 'AzureDevOpsRateLimitError'; 103 | this.resetAt = resetAt; 104 | } 105 | } 106 | 107 | /** 108 | * Helper function to check if an error is an Azure DevOps error. 109 | * Useful for type narrowing in catch blocks. 110 | * 111 | * @param {unknown} error - The error to check 112 | * @returns {boolean} True if the error is an Azure DevOps error 113 | * 114 | * @example 115 | * try { 116 | * // Some Azure DevOps operation 117 | * } catch (error) { 118 | * if (isAzureDevOpsError(error)) { 119 | * // Handle Azure DevOps specific error 120 | * } else { 121 | * // Handle other errors 122 | * } 123 | * } 124 | */ 125 | export function isAzureDevOpsError(error: unknown): error is AzureDevOpsError { 126 | return error instanceof AzureDevOpsError; 127 | } 128 | 129 | /** 130 | * Format an Azure DevOps error for display. 131 | * Provides a consistent error message format across different error types. 132 | * 133 | * @param {unknown} error - The error to format 134 | * @returns {string} A formatted error message 135 | * 136 | * @example 137 | * try { 138 | * // Some Azure DevOps operation 139 | * } catch (error) { 140 | * console.error(formatAzureDevOpsError(error)); 141 | * } 142 | */ 143 | export function formatAzureDevOpsError(error: unknown): string { 144 | // Handle non-error objects 145 | if (error === null) { 146 | return 'null'; 147 | } 148 | 149 | if (error === undefined) { 150 | return 'undefined'; 151 | } 152 | 153 | if (typeof error === 'string') { 154 | return error; 155 | } 156 | 157 | if (typeof error === 'number' || typeof error === 'boolean') { 158 | return String(error); 159 | } 160 | 161 | // Handle error-like objects 162 | const errorObj = error as Record<string, unknown>; 163 | let message = `${errorObj.name || 'Unknown'}: ${errorObj.message || 'Unknown error'}`; 164 | 165 | if (error instanceof AzureDevOpsValidationError) { 166 | if (error.response) { 167 | message += `\nResponse: ${JSON.stringify(error.response)}`; 168 | } else { 169 | message += '\nNo response details available'; 170 | } 171 | } else if (error instanceof AzureDevOpsRateLimitError) { 172 | message += `\nReset at: ${error.resetAt.toISOString()}`; 173 | } 174 | 175 | return message; 176 | } 177 | ``` -------------------------------------------------------------------------------- /docs/tools/resources.md: -------------------------------------------------------------------------------- ```markdown 1 | # Azure DevOps Resource URIs 2 | 3 | In addition to tools, the Azure DevOps MCP server provides access to resources via standardized URI patterns. Resources allow AI assistants to directly reference and retrieve content from Azure DevOps repositories using simple, predictable URLs. 4 | 5 | ## Repository Content Resources 6 | 7 | The server supports accessing files and directories from Git repositories using the following resource URI patterns. 8 | 9 | ### Available Resource URI Templates 10 | 11 | | Resource Type | URI Template | Description | 12 | | ------------- | ------------ | ----------- | 13 | | Default Branch Content | `ado://{organization}/{project}/{repo}/contents{/path*}` | Access file or directory content from the default branch | 14 | | Branch-Specific Content | `ado://{organization}/{project}/{repo}/branches/{branch}/contents{/path*}` | Access content from a specific branch | 15 | | Commit-Specific Content | `ado://{organization}/{project}/{repo}/commits/{commit}/contents{/path*}` | Access content from a specific commit | 16 | | Tag-Specific Content | `ado://{organization}/{project}/{repo}/tags/{tag}/contents{/path*}` | Access content from a specific tag | 17 | | Pull Request Content | `ado://{organization}/{project}/{repo}/pullrequests/{prId}/contents{/path*}` | Access content from a pull request | 18 | 19 | ### URI Components 20 | 21 | - `{organization}`: Your Azure DevOps organization name 22 | - `{project}`: The project name or ID 23 | - `{repo}`: The repository name or ID 24 | - `{path*}`: The path to the file or directory within the repository (optional) 25 | - `{branch}`: The name of a branch 26 | - `{commit}`: The SHA-1 hash of a commit 27 | - `{tag}`: The name of a tag 28 | - `{prId}`: The ID of a pull request 29 | 30 | ## Examples 31 | 32 | ### Accessing Files from the Default Branch 33 | 34 | To access the content of a file in the default branch: 35 | 36 | ``` 37 | ado://myorg/MyProject/MyRepo/contents/src/index.ts 38 | ``` 39 | 40 | This retrieves the content of `index.ts` from the `src` directory in the default branch. 41 | 42 | ### Accessing Directory Content 43 | 44 | To list the contents of a directory: 45 | 46 | ``` 47 | ado://myorg/MyProject/MyRepo/contents/src 48 | ``` 49 | 50 | This returns a JSON array containing information about all items in the `src` directory. 51 | 52 | ### Accessing Content from a Specific Branch 53 | 54 | To access content from a feature branch: 55 | 56 | ``` 57 | ado://myorg/MyProject/MyRepo/branches/feature/new-ui/contents/src/index.ts 58 | ``` 59 | 60 | This retrieves the content of `index.ts` from the `feature/new-ui` branch. 61 | 62 | ### Accessing Content from a Specific Commit 63 | 64 | To access content at a specific commit: 65 | 66 | ``` 67 | ado://myorg/MyProject/MyRepo/commits/a1b2c3d4e5f6g7h8i9j0/contents/src/index.ts 68 | ``` 69 | 70 | This retrieves the version of `index.ts` at the specified commit. 71 | 72 | ### Accessing Content from a Tag 73 | 74 | To access content from a tagged release: 75 | 76 | ``` 77 | ado://myorg/MyProject/MyRepo/tags/v1.0.0/contents/README.md 78 | ``` 79 | 80 | This retrieves the README.md file from the v1.0.0 tag. 81 | 82 | ### Accessing Content from a Pull Request 83 | 84 | To access content from a pull request: 85 | 86 | ``` 87 | ado://myorg/MyProject/MyRepo/pullrequests/42/contents/src/index.ts 88 | ``` 89 | 90 | This retrieves the version of `index.ts` from pull request #42. 91 | 92 | ## Implementation Details 93 | 94 | When a resource URI is requested, the server: 95 | 96 | 1. Parses the URI to extract the components (organization, project, repository, path, etc.) 97 | 2. Establishes a connection to Azure DevOps using the configured authentication method 98 | 3. Determines if a specific version (branch, commit, tag) is requested 99 | 4. Uses the `getFileContent` functionality to retrieve the content 100 | 5. Returns the content with the appropriate MIME type 101 | 102 | ## Response Format 103 | 104 | Responses are returned with the appropriate MIME type based on the file extension. For example: 105 | 106 | - `.ts`, `.tsx` files: `application/typescript` 107 | - `.js` files: `application/javascript` 108 | - `.json` files: `application/json` 109 | - `.md` files: `text/markdown` 110 | - `.txt` files: `text/plain` 111 | - `.html`, `.htm` files: `text/html` 112 | - Image files (`.png`, `.jpg`, `.gif`, etc.): appropriate image MIME types 113 | 114 | For directories, the content is returned as a JSON array with MIME type `application/json`. 115 | 116 | ## Error Handling 117 | 118 | The resource handler may throw the following errors: 119 | 120 | - `AzureDevOpsResourceNotFoundError`: If the specified resource cannot be found (project, repository, path, or version) 121 | - `AzureDevOpsAuthenticationError`: If authentication fails 122 | - `AzureDevOpsValidationError`: If the URI format is invalid 123 | - Other errors: For unexpected issues 124 | 125 | ## Related Tools 126 | 127 | While resource URIs provide direct access to repository content, you can also use the following tools for more advanced operations: 128 | 129 | - `get_file_content`: Get content of a file or directory with more options and metadata 130 | - `get_repository`: Get details about a specific repository 131 | - `get_repository_details`: Get comprehensive repository information including statistics and refs 132 | - `list_repositories`: List all repositories in a project 133 | - `search_code`: Search for code in repositories ``` -------------------------------------------------------------------------------- /src/features/work-items/list-work-items/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { listWorkItems } from './feature'; 3 | import { createWorkItem } from '../create-work-item/feature'; 4 | import { 5 | getTestConnection, 6 | shouldSkipIntegrationTest, 7 | } from '@/shared/test/test-helpers'; 8 | import { CreateWorkItemOptions, ListWorkItemsOptions } from '../types'; 9 | 10 | describe('listWorkItems integration', () => { 11 | let connection: WebApi | null = null; 12 | const createdWorkItemIds: number[] = []; 13 | let projectName: string; 14 | 15 | beforeAll(async () => { 16 | // Get a real connection using environment variables 17 | connection = await getTestConnection(); 18 | 19 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 20 | 21 | // Skip setup if integration tests should be skipped 22 | if (shouldSkipIntegrationTest() || !connection) { 23 | return; 24 | } 25 | 26 | // Create a few work items to ensure we have data to list 27 | const testPrefix = `List Test ${new Date().toISOString().slice(0, 16)}`; 28 | 29 | for (let i = 0; i < 3; i++) { 30 | const options: CreateWorkItemOptions = { 31 | title: `${testPrefix} - Item ${i + 1}`, 32 | description: `Test item ${i + 1} for list-work-items integration tests`, 33 | priority: 2, 34 | additionalFields: { 35 | 'System.Tags': 'ListTest,Integration', 36 | }, 37 | }; 38 | 39 | try { 40 | const workItem = await createWorkItem( 41 | connection, 42 | projectName, 43 | 'Task', 44 | options, 45 | ); 46 | if (workItem && workItem.id !== undefined) { 47 | createdWorkItemIds.push(workItem.id); 48 | } 49 | } catch (error) { 50 | console.error(`Failed to create test work item ${i + 1}:`, error); 51 | } 52 | } 53 | }); 54 | 55 | test('should list work items from a project', async () => { 56 | // Skip if no connection is available 57 | if (shouldSkipIntegrationTest() || !connection) { 58 | return; 59 | } 60 | 61 | const options: ListWorkItemsOptions = { 62 | projectId: projectName, 63 | }; 64 | 65 | // Act - make an actual API call to Azure DevOps 66 | const result = await listWorkItems(connection, options); 67 | 68 | // Assert on the actual response 69 | expect(result).toBeDefined(); 70 | expect(Array.isArray(result)).toBe(true); 71 | 72 | // Should have at least some work items (including our created ones) 73 | expect(result.length).toBeGreaterThan(0); 74 | 75 | // Check basic structure of returned work items 76 | const firstItem = result[0]; 77 | expect(firstItem.id).toBeDefined(); 78 | expect(firstItem.fields).toBeDefined(); 79 | 80 | if (firstItem.fields) { 81 | expect(firstItem.fields['System.Title']).toBeDefined(); 82 | } 83 | }); 84 | 85 | test('should apply pagination options', async () => { 86 | // Skip if no connection is available 87 | if (shouldSkipIntegrationTest() || !connection) { 88 | return; 89 | } 90 | 91 | // First get all items to know the total count 92 | const allOptions: ListWorkItemsOptions = { 93 | projectId: projectName, 94 | }; 95 | 96 | const allItems = await listWorkItems(connection, allOptions); 97 | 98 | // Then get with pagination 99 | const paginationOptions: ListWorkItemsOptions = { 100 | projectId: projectName, 101 | top: 2, // Only get first 2 items 102 | }; 103 | 104 | const paginatedResult = await listWorkItems(connection, paginationOptions); 105 | 106 | // Assert on pagination 107 | expect(paginatedResult).toBeDefined(); 108 | expect(paginatedResult.length).toBeLessThanOrEqual(2); 109 | 110 | // If we have more than 2 total items, pagination should have limited results 111 | if (allItems.length > 2) { 112 | expect(paginatedResult.length).toBe(2); 113 | expect(paginatedResult.length).toBeLessThan(allItems.length); 114 | } 115 | }); 116 | 117 | test('should list work items with custom WIQL query', async () => { 118 | // Skip if no connection is available or if we didn't create any test items 119 | if ( 120 | shouldSkipIntegrationTest() || 121 | !connection || 122 | createdWorkItemIds.length === 0 123 | ) { 124 | return; 125 | } 126 | 127 | // Create a more specific WIQL query that includes the IDs of our created work items 128 | const workItemIdList = createdWorkItemIds.join(','); 129 | const wiql = `SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.TeamProject] = '${projectName}' AND [System.Id] IN (${workItemIdList}) AND [System.Tags] CONTAINS 'ListTest' ORDER BY [System.Id]`; 130 | 131 | const options: ListWorkItemsOptions = { 132 | projectId: projectName, 133 | wiql, 134 | }; 135 | 136 | // Act - make an actual API call to Azure DevOps 137 | const result = await listWorkItems(connection, options); 138 | 139 | // Assert on the actual response 140 | expect(result).toBeDefined(); 141 | expect(Array.isArray(result)).toBe(true); 142 | 143 | // Should have found our test items with the ListTest tag 144 | expect(result.length).toBeGreaterThan(0); 145 | 146 | // At least one of our created items should be in the results 147 | const foundCreatedItem = result.some((item) => 148 | createdWorkItemIds.includes(item.id || -1), 149 | ); 150 | 151 | expect(foundCreatedItem).toBe(true); 152 | }); 153 | }); 154 | ``` -------------------------------------------------------------------------------- /src/features/users/get-me/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios, { AxiosError } from 'axios'; 3 | import { getMe } from './feature'; 4 | import { 5 | AzureDevOpsError, 6 | AzureDevOpsAuthenticationError, 7 | } from '@/shared/errors'; 8 | 9 | // Mock axios 10 | jest.mock('axios'); 11 | const mockAxios = axios as jest.Mocked<typeof axios>; 12 | 13 | // Mock env variables 14 | const originalEnv = process.env; 15 | 16 | describe('getMe', () => { 17 | let mockConnection: WebApi; 18 | 19 | beforeEach(() => { 20 | // Reset mocks 21 | jest.resetAllMocks(); 22 | 23 | // Mock WebApi with a server URL 24 | mockConnection = { 25 | serverUrl: 'https://dev.azure.com/testorg', 26 | } as WebApi; 27 | 28 | // Mock environment variables for PAT authentication 29 | process.env = { 30 | ...originalEnv, 31 | AZURE_DEVOPS_AUTH_METHOD: 'pat', 32 | AZURE_DEVOPS_PAT: 'test-pat', 33 | }; 34 | }); 35 | 36 | afterEach(() => { 37 | // Restore original env 38 | process.env = originalEnv; 39 | }); 40 | 41 | it('should return user profile with id, displayName, and email', async () => { 42 | // Arrange 43 | const mockProfile = { 44 | id: 'user-id-123', 45 | displayName: 'Test User', 46 | emailAddress: '[email protected]', 47 | coreRevision: 1647, 48 | timeStamp: '2023-01-01T00:00:00.000Z', 49 | revision: 1647, 50 | }; 51 | 52 | // Mock axios get to return profile data 53 | mockAxios.get.mockResolvedValue({ data: mockProfile }); 54 | 55 | // Act 56 | const result = await getMe(mockConnection); 57 | 58 | // Assert 59 | expect(mockAxios.get).toHaveBeenCalledWith( 60 | 'https://vssps.dev.azure.com/testorg/_apis/profile/profiles/me?api-version=7.1', 61 | expect.any(Object), 62 | ); 63 | 64 | expect(result).toEqual({ 65 | id: 'user-id-123', 66 | displayName: 'Test User', 67 | email: '[email protected]', 68 | }); 69 | }); 70 | 71 | it('should handle missing email', async () => { 72 | // Arrange 73 | const mockProfile = { 74 | id: 'user-id-123', 75 | displayName: 'Test User', 76 | // No emailAddress 77 | coreRevision: 1647, 78 | timeStamp: '2023-01-01T00:00:00.000Z', 79 | revision: 1647, 80 | }; 81 | 82 | // Mock axios get to return profile data 83 | mockAxios.get.mockResolvedValue({ data: mockProfile }); 84 | 85 | // Act 86 | const result = await getMe(mockConnection); 87 | 88 | // Assert 89 | expect(result.email).toBe(''); 90 | }); 91 | 92 | it('should handle missing display name', async () => { 93 | // Arrange 94 | const mockProfile = { 95 | id: 'user-id-123', 96 | // No displayName 97 | emailAddress: '[email protected]', 98 | coreRevision: 1647, 99 | timeStamp: '2023-01-01T00:00:00.000Z', 100 | revision: 1647, 101 | }; 102 | 103 | // Mock axios get to return profile data 104 | mockAxios.get.mockResolvedValue({ data: mockProfile }); 105 | 106 | // Act 107 | const result = await getMe(mockConnection); 108 | 109 | // Assert 110 | expect(result.displayName).toBe(''); 111 | }); 112 | 113 | it('should handle authentication errors', async () => { 114 | // Arrange 115 | const axiosError = { 116 | isAxiosError: true, 117 | response: { 118 | status: 401, 119 | data: { message: 'Unauthorized' }, 120 | }, 121 | message: 'Request failed with status code 401', 122 | } as AxiosError; 123 | 124 | // Mock axios get to throw error 125 | mockAxios.get.mockRejectedValue(axiosError); 126 | 127 | // Mock axios.isAxiosError function 128 | jest.spyOn(axios, 'isAxiosError').mockImplementation(() => true); 129 | 130 | // Act & Assert 131 | await expect(getMe(mockConnection)).rejects.toThrow( 132 | AzureDevOpsAuthenticationError, 133 | ); 134 | await expect(getMe(mockConnection)).rejects.toThrow( 135 | /Authentication failed/, 136 | ); 137 | }); 138 | 139 | it('should wrap general errors in AzureDevOpsError', async () => { 140 | // Arrange 141 | const testError = new Error('Test API error'); 142 | mockAxios.get.mockRejectedValue(testError); 143 | 144 | // Mock axios.isAxiosError function 145 | jest.spyOn(axios, 'isAxiosError').mockImplementation(() => false); 146 | 147 | // Act & Assert 148 | await expect(getMe(mockConnection)).rejects.toThrow(AzureDevOpsError); 149 | await expect(getMe(mockConnection)).rejects.toThrow( 150 | 'Failed to get user information: Test API error', 151 | ); 152 | }); 153 | 154 | // Test the legacy URL format of project.visualstudio.com 155 | it('should work with legacy visualstudio.com URL format', async () => { 156 | mockConnection = { 157 | serverUrl: 'https://legacy_test_org.visualstudio.com', 158 | } as WebApi; 159 | 160 | const mockProfile = { 161 | id: 'user-id-123', 162 | displayName: 'Test User', 163 | emailAddress: '[email protected]', 164 | coreRevision: 1647, 165 | timeStamp: '2023-01-01T00:00:00.000Z', 166 | revision: 1647, 167 | }; 168 | 169 | mockAxios.get.mockResolvedValue({ data: mockProfile }); 170 | 171 | const result = await getMe(mockConnection); 172 | 173 | // Verify that the organization name was correctly extracted from the legacy URL 174 | expect(mockAxios.get).toHaveBeenCalledWith( 175 | 'https://vssps.dev.azure.com/legacy_test_org/_apis/profile/profiles/me?api-version=7.1', 176 | expect.any(Object), 177 | ); 178 | 179 | expect(result).toEqual({ 180 | id: 'user-id-123', 181 | displayName: 'Test User', 182 | email: '[email protected]', 183 | }); 184 | }); 185 | }); 186 | ``` -------------------------------------------------------------------------------- /src/features/work-items/schemas.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { defaultProject, defaultOrg } from '../../utils/environment'; 3 | 4 | /** 5 | * Schema for getting a work item 6 | */ 7 | export const GetWorkItemSchema = z.object({ 8 | workItemId: z.number().describe('The ID of the work item'), 9 | expand: z 10 | .enum(['none', 'relations', 'fields', 'links', 'all']) 11 | .optional() 12 | .describe( 13 | 'The level of detail to include in the response. Defaults to "all" if not specified.', 14 | ), 15 | }); 16 | 17 | /** 18 | * Schema for listing work items 19 | */ 20 | export const ListWorkItemsSchema = z.object({ 21 | projectId: z 22 | .string() 23 | .optional() 24 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 25 | organizationId: z 26 | .string() 27 | .optional() 28 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 29 | teamId: z.string().optional().describe('The ID of the team'), 30 | queryId: z.string().optional().describe('ID of a saved work item query'), 31 | wiql: z.string().optional().describe('Work Item Query Language (WIQL) query'), 32 | top: z.number().optional().describe('Maximum number of work items to return'), 33 | skip: z.number().optional().describe('Number of work items to skip'), 34 | }); 35 | 36 | /** 37 | * Schema for creating a work item 38 | */ 39 | export const CreateWorkItemSchema = z.object({ 40 | projectId: z 41 | .string() 42 | .optional() 43 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 44 | organizationId: z 45 | .string() 46 | .optional() 47 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 48 | workItemType: z 49 | .string() 50 | .describe( 51 | 'The type of work item to create (e.g., "Task", "Bug", "User Story")', 52 | ), 53 | title: z.string().describe('The title of the work item'), 54 | description: z 55 | .string() 56 | .optional() 57 | .describe( 58 | 'Work item description in HTML format. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.', 59 | ), 60 | assignedTo: z 61 | .string() 62 | .optional() 63 | .describe('The email or name of the user to assign the work item to'), 64 | areaPath: z.string().optional().describe('The area path for the work item'), 65 | iterationPath: z 66 | .string() 67 | .optional() 68 | .describe('The iteration path for the work item'), 69 | priority: z.number().optional().describe('The priority of the work item'), 70 | parentId: z 71 | .number() 72 | .optional() 73 | .describe('The ID of the parent work item to create a relationship with'), 74 | additionalFields: z 75 | .record(z.string(), z.any()) 76 | .optional() 77 | .describe( 78 | 'Additional fields to set on the work item. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.', 79 | ), 80 | }); 81 | 82 | /** 83 | * Schema for updating a work item 84 | */ 85 | export const UpdateWorkItemSchema = z.object({ 86 | workItemId: z.number().describe('The ID of the work item to update'), 87 | title: z.string().optional().describe('The updated title of the work item'), 88 | description: z 89 | .string() 90 | .optional() 91 | .describe( 92 | 'Work item description in HTML format. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.', 93 | ), 94 | assignedTo: z 95 | .string() 96 | .optional() 97 | .describe('The email or name of the user to assign the work item to'), 98 | areaPath: z 99 | .string() 100 | .optional() 101 | .describe('The updated area path for the work item'), 102 | iterationPath: z 103 | .string() 104 | .optional() 105 | .describe('The updated iteration path for the work item'), 106 | priority: z 107 | .number() 108 | .optional() 109 | .describe('The updated priority of the work item'), 110 | state: z.string().optional().describe('The updated state of the work item'), 111 | additionalFields: z 112 | .record(z.string(), z.any()) 113 | .optional() 114 | .describe( 115 | 'Additional fields to update on the work item. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.', 116 | ), 117 | }); 118 | 119 | /** 120 | * Schema for managing work item links 121 | */ 122 | export const ManageWorkItemLinkSchema = z.object({ 123 | sourceWorkItemId: z.number().describe('The ID of the source work item'), 124 | targetWorkItemId: z.number().describe('The ID of the target work item'), 125 | projectId: z 126 | .string() 127 | .optional() 128 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 129 | organizationId: z 130 | .string() 131 | .optional() 132 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 133 | operation: z 134 | .enum(['add', 'remove', 'update']) 135 | .describe('The operation to perform on the link'), 136 | relationType: z 137 | .string() 138 | .describe( 139 | 'The reference name of the relation type (e.g., "System.LinkTypes.Hierarchy-Forward")', 140 | ), 141 | newRelationType: z 142 | .string() 143 | .optional() 144 | .describe('The new relation type to use when updating a link'), 145 | comment: z 146 | .string() 147 | .optional() 148 | .describe('Optional comment explaining the link'), 149 | }); 150 | ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getConnection } from '../../../server'; 2 | import { shouldSkipIntegrationTest } from '../../../shared/test/test-helpers'; 3 | import { getAllRepositoriesTree } from './feature'; 4 | import { AzureDevOpsConfig } from '../../../shared/types'; 5 | import { WebApi } from 'azure-devops-node-api'; 6 | import { AuthenticationMethod } from '../../../shared/auth'; 7 | 8 | // Skip tests if no PAT is available 9 | const hasPat = process.env.AZURE_DEVOPS_PAT && process.env.AZURE_DEVOPS_ORG_URL; 10 | const describeOrSkip = hasPat ? describe : describe.skip; 11 | 12 | describeOrSkip('getAllRepositoriesTree (Integration)', () => { 13 | let connection: WebApi; 14 | let config: AzureDevOpsConfig; 15 | let projectId: string; 16 | let orgId: string; 17 | 18 | beforeAll(async () => { 19 | if (shouldSkipIntegrationTest()) { 20 | return; 21 | } 22 | 23 | // Configuration values 24 | config = { 25 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', 26 | authMethod: AuthenticationMethod.PersonalAccessToken, 27 | personalAccessToken: process.env.AZURE_DEVOPS_PAT || '', 28 | defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT || '', 29 | }; 30 | 31 | // Use test project - should be defined in .env file 32 | projectId = 33 | process.env.AZURE_DEVOPS_TEST_PROJECT_ID || 34 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 35 | ''; 36 | 37 | // Extract organization ID from URL 38 | const url = new URL(config.organizationUrl); 39 | const pathParts = url.pathname.split('/').filter(Boolean); 40 | orgId = pathParts[0] || ''; 41 | 42 | // Get Azure DevOps connection 43 | connection = await getConnection(config); 44 | 45 | // Skip tests if no project ID is set 46 | if (!projectId) { 47 | console.warn('Skipping integration tests: No test project ID set'); 48 | } 49 | }, 30000); 50 | 51 | // Skip all tests if integration tests are disabled 52 | beforeEach(() => { 53 | if (shouldSkipIntegrationTest()) { 54 | jest.resetAllMocks(); 55 | return; 56 | } 57 | }); 58 | 59 | it('should retrieve tree for all repositories with maximum depth (default)', async () => { 60 | // Skip test if no project ID or if integration tests are disabled 61 | if (shouldSkipIntegrationTest() || !projectId) { 62 | return; 63 | } 64 | 65 | const result = await getAllRepositoriesTree(connection, { 66 | organizationId: orgId, 67 | projectId: projectId, 68 | // depth defaults to 0 (unlimited) 69 | }); 70 | 71 | expect(result).toBeDefined(); 72 | expect(result.repositories).toBeDefined(); 73 | expect(Array.isArray(result.repositories)).toBe(true); 74 | expect(result.repositories.length).toBeGreaterThan(0); 75 | 76 | // Check that at least one repository has a tree 77 | const repoWithTree = result.repositories.find((r) => r.tree.length > 0); 78 | expect(repoWithTree).toBeDefined(); 79 | 80 | if (repoWithTree) { 81 | // Verify that deep nesting is included (finding items with level > 2) 82 | // Note: This might not always be true depending on repos, but there should be at least some nested items 83 | const deepItems = repoWithTree.tree.filter((item) => item.level > 2); 84 | expect(deepItems.length).toBeGreaterThan(0); 85 | 86 | // Verify stats are correct 87 | expect(repoWithTree.stats.directories).toBeGreaterThanOrEqual(0); 88 | expect(repoWithTree.stats.files).toBeGreaterThan(0); 89 | const dirCount = repoWithTree.tree.filter((item) => item.isFolder).length; 90 | const fileCount = repoWithTree.tree.filter( 91 | (item) => !item.isFolder, 92 | ).length; 93 | expect(repoWithTree.stats.directories).toBe(dirCount); 94 | expect(repoWithTree.stats.files).toBe(fileCount); 95 | } 96 | }, 60000); // Longer timeout because max depth can take time 97 | 98 | it('should retrieve tree for all repositories with limited depth (depth=1)', async () => { 99 | // Skip test if no project ID or if integration tests are disabled 100 | if (shouldSkipIntegrationTest() || !projectId) { 101 | return; 102 | } 103 | 104 | const result = await getAllRepositoriesTree(connection, { 105 | organizationId: orgId, 106 | projectId: projectId, 107 | depth: 1, // Only 1 level deep 108 | }); 109 | 110 | expect(result).toBeDefined(); 111 | expect(result.repositories).toBeDefined(); 112 | expect(Array.isArray(result.repositories)).toBe(true); 113 | expect(result.repositories.length).toBeGreaterThan(0); 114 | 115 | // Check that at least one repository has a tree 116 | const repoWithTree = result.repositories.find((r) => r.tree.length > 0); 117 | expect(repoWithTree).toBeDefined(); 118 | 119 | if (repoWithTree) { 120 | // Verify that only shallow nesting is included (all items should have level = 1) 121 | const allItemsLevel1 = repoWithTree.tree.every( 122 | (item) => item.level === 1, 123 | ); 124 | expect(allItemsLevel1).toBe(true); 125 | 126 | // Verify stats are correct 127 | expect(repoWithTree.stats.directories).toBeGreaterThanOrEqual(0); 128 | expect(repoWithTree.stats.files).toBeGreaterThanOrEqual(0); 129 | const dirCount = repoWithTree.tree.filter((item) => item.isFolder).length; 130 | const fileCount = repoWithTree.tree.filter( 131 | (item) => !item.isFolder, 132 | ).length; 133 | expect(repoWithTree.stats.directories).toBe(dirCount); 134 | expect(repoWithTree.stats.files).toBe(fileCount); 135 | } 136 | }, 30000); 137 | }); 138 | ``` -------------------------------------------------------------------------------- /src/shared/enums/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | CommentThreadStatus, 3 | CommentType, 4 | GitVersionType, 5 | PullRequestStatus, 6 | } from 'azure-devops-node-api/interfaces/GitInterfaces'; 7 | import { 8 | commentThreadStatusMapper, 9 | commentTypeMapper, 10 | pullRequestStatusMapper, 11 | gitVersionTypeMapper, 12 | } from './index'; 13 | 14 | describe('Enum Mappers', () => { 15 | describe('commentThreadStatusMapper', () => { 16 | it('should map string values to enum values correctly', () => { 17 | expect(commentThreadStatusMapper.toEnum('active')).toBe( 18 | CommentThreadStatus.Active, 19 | ); 20 | expect(commentThreadStatusMapper.toEnum('fixed')).toBe( 21 | CommentThreadStatus.Fixed, 22 | ); 23 | expect(commentThreadStatusMapper.toEnum('wontfix')).toBe( 24 | CommentThreadStatus.WontFix, 25 | ); 26 | expect(commentThreadStatusMapper.toEnum('closed')).toBe( 27 | CommentThreadStatus.Closed, 28 | ); 29 | expect(commentThreadStatusMapper.toEnum('bydesign')).toBe( 30 | CommentThreadStatus.ByDesign, 31 | ); 32 | expect(commentThreadStatusMapper.toEnum('pending')).toBe( 33 | CommentThreadStatus.Pending, 34 | ); 35 | expect(commentThreadStatusMapper.toEnum('unknown')).toBe( 36 | CommentThreadStatus.Unknown, 37 | ); 38 | }); 39 | 40 | it('should map enum values to string values correctly', () => { 41 | expect( 42 | commentThreadStatusMapper.toString(CommentThreadStatus.Active), 43 | ).toBe('active'); 44 | expect( 45 | commentThreadStatusMapper.toString(CommentThreadStatus.Fixed), 46 | ).toBe('fixed'); 47 | expect( 48 | commentThreadStatusMapper.toString(CommentThreadStatus.WontFix), 49 | ).toBe('wontfix'); 50 | expect( 51 | commentThreadStatusMapper.toString(CommentThreadStatus.Closed), 52 | ).toBe('closed'); 53 | expect( 54 | commentThreadStatusMapper.toString(CommentThreadStatus.ByDesign), 55 | ).toBe('bydesign'); 56 | expect( 57 | commentThreadStatusMapper.toString(CommentThreadStatus.Pending), 58 | ).toBe('pending'); 59 | expect( 60 | commentThreadStatusMapper.toString(CommentThreadStatus.Unknown), 61 | ).toBe('unknown'); 62 | }); 63 | 64 | it('should handle case insensitive string input', () => { 65 | expect(commentThreadStatusMapper.toEnum('ACTIVE')).toBe( 66 | CommentThreadStatus.Active, 67 | ); 68 | expect(commentThreadStatusMapper.toEnum('Active')).toBe( 69 | CommentThreadStatus.Active, 70 | ); 71 | }); 72 | 73 | it('should return undefined for invalid string values', () => { 74 | expect(commentThreadStatusMapper.toEnum('invalid')).toBeUndefined(); 75 | }); 76 | 77 | it('should return default value for invalid enum values', () => { 78 | expect(commentThreadStatusMapper.toString(999)).toBe('unknown'); 79 | }); 80 | }); 81 | 82 | describe('commentTypeMapper', () => { 83 | it('should map string values to enum values correctly', () => { 84 | expect(commentTypeMapper.toEnum('text')).toBe(CommentType.Text); 85 | expect(commentTypeMapper.toEnum('codechange')).toBe( 86 | CommentType.CodeChange, 87 | ); 88 | expect(commentTypeMapper.toEnum('system')).toBe(CommentType.System); 89 | expect(commentTypeMapper.toEnum('unknown')).toBe(CommentType.Unknown); 90 | }); 91 | 92 | it('should map enum values to string values correctly', () => { 93 | expect(commentTypeMapper.toString(CommentType.Text)).toBe('text'); 94 | expect(commentTypeMapper.toString(CommentType.CodeChange)).toBe( 95 | 'codechange', 96 | ); 97 | expect(commentTypeMapper.toString(CommentType.System)).toBe('system'); 98 | expect(commentTypeMapper.toString(CommentType.Unknown)).toBe('unknown'); 99 | }); 100 | }); 101 | 102 | describe('pullRequestStatusMapper', () => { 103 | it('should map string values to enum values correctly', () => { 104 | expect(pullRequestStatusMapper.toEnum('active')).toBe( 105 | PullRequestStatus.Active, 106 | ); 107 | expect(pullRequestStatusMapper.toEnum('abandoned')).toBe( 108 | PullRequestStatus.Abandoned, 109 | ); 110 | expect(pullRequestStatusMapper.toEnum('completed')).toBe( 111 | PullRequestStatus.Completed, 112 | ); 113 | }); 114 | 115 | it('should map enum values to string values correctly', () => { 116 | expect(pullRequestStatusMapper.toString(PullRequestStatus.Active)).toBe( 117 | 'active', 118 | ); 119 | expect( 120 | pullRequestStatusMapper.toString(PullRequestStatus.Abandoned), 121 | ).toBe('abandoned'); 122 | expect( 123 | pullRequestStatusMapper.toString(PullRequestStatus.Completed), 124 | ).toBe('completed'); 125 | }); 126 | }); 127 | 128 | describe('gitVersionTypeMapper', () => { 129 | it('should map string values to enum values correctly', () => { 130 | expect(gitVersionTypeMapper.toEnum('branch')).toBe(GitVersionType.Branch); 131 | expect(gitVersionTypeMapper.toEnum('commit')).toBe(GitVersionType.Commit); 132 | expect(gitVersionTypeMapper.toEnum('tag')).toBe(GitVersionType.Tag); 133 | }); 134 | 135 | it('should map enum values to string values correctly', () => { 136 | expect(gitVersionTypeMapper.toString(GitVersionType.Branch)).toBe( 137 | 'branch', 138 | ); 139 | expect(gitVersionTypeMapper.toString(GitVersionType.Commit)).toBe( 140 | 'commit', 141 | ); 142 | expect(gitVersionTypeMapper.toString(GitVersionType.Tag)).toBe('tag'); 143 | }); 144 | }); 145 | }); 146 | ``` -------------------------------------------------------------------------------- /src/features/search/schemas.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { defaultOrg, defaultProject } from '../../utils/environment'; 3 | 4 | /** 5 | * Schema for searching code in Azure DevOps repositories 6 | */ 7 | export const SearchCodeSchema = z 8 | .object({ 9 | searchText: z.string().describe('The text to search for'), 10 | organizationId: z 11 | .string() 12 | .optional() 13 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 14 | projectId: z 15 | .string() 16 | .optional() 17 | .describe( 18 | `The ID or name of the project to search in (Default: ${defaultProject}). If not provided, the default project will be used.`, 19 | ), 20 | filters: z 21 | .object({ 22 | Repository: z 23 | .array(z.string()) 24 | .optional() 25 | .describe('Filter by repository names'), 26 | Path: z.array(z.string()).optional().describe('Filter by file paths'), 27 | Branch: z 28 | .array(z.string()) 29 | .optional() 30 | .describe('Filter by branch names'), 31 | CodeElement: z 32 | .array(z.string()) 33 | .optional() 34 | .describe('Filter by code element types (function, class, etc.)'), 35 | }) 36 | .optional() 37 | .describe('Optional filters to narrow search results'), 38 | top: z 39 | .number() 40 | .int() 41 | .min(1) 42 | .max(1000) 43 | .default(100) 44 | .describe('Number of results to return (default: 100, max: 1000)'), 45 | skip: z 46 | .number() 47 | .int() 48 | .min(0) 49 | .default(0) 50 | .describe('Number of results to skip for pagination (default: 0)'), 51 | includeSnippet: z 52 | .boolean() 53 | .default(true) 54 | .describe('Whether to include code snippets in results (default: true)'), 55 | includeContent: z 56 | .boolean() 57 | .default(true) 58 | .describe( 59 | 'Whether to include full file content in results (default: true)', 60 | ), 61 | }) 62 | .transform((data) => { 63 | return { 64 | ...data, 65 | organizationId: data.organizationId ?? defaultOrg, 66 | projectId: data.projectId ?? defaultProject, 67 | }; 68 | }); 69 | 70 | /** 71 | * Schema for searching wiki pages in Azure DevOps projects 72 | */ 73 | export const SearchWikiSchema = z.object({ 74 | searchText: z.string().describe('The text to search for in wikis'), 75 | organizationId: z 76 | .string() 77 | .optional() 78 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 79 | projectId: z 80 | .string() 81 | .optional() 82 | .describe( 83 | `The ID or name of the project to search in (Default: ${defaultProject}). If not provided, the default project will be used.`, 84 | ), 85 | filters: z 86 | .object({ 87 | Project: z 88 | .array(z.string()) 89 | .optional() 90 | .describe('Filter by project names'), 91 | }) 92 | .optional() 93 | .describe('Optional filters to narrow search results'), 94 | top: z 95 | .number() 96 | .int() 97 | .min(1) 98 | .max(1000) 99 | .default(100) 100 | .describe('Number of results to return (default: 100, max: 1000)'), 101 | skip: z 102 | .number() 103 | .int() 104 | .min(0) 105 | .default(0) 106 | .describe('Number of results to skip for pagination (default: 0)'), 107 | includeFacets: z 108 | .boolean() 109 | .default(true) 110 | .describe('Whether to include faceting in results (default: true)'), 111 | }); 112 | 113 | /** 114 | * Schema for searching work items in Azure DevOps projects 115 | */ 116 | export const SearchWorkItemsSchema = z.object({ 117 | searchText: z.string().describe('The text to search for in work items'), 118 | organizationId: z 119 | .string() 120 | .optional() 121 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 122 | projectId: z 123 | .string() 124 | .optional() 125 | .describe( 126 | `The ID or name of the project to search in (Default: ${defaultProject}). If not provided, the default project will be used.`, 127 | ), 128 | filters: z 129 | .object({ 130 | 'System.TeamProject': z 131 | .array(z.string()) 132 | .optional() 133 | .describe('Filter by project names'), 134 | 'System.WorkItemType': z 135 | .array(z.string()) 136 | .optional() 137 | .describe('Filter by work item types (Bug, Task, User Story, etc.)'), 138 | 'System.State': z 139 | .array(z.string()) 140 | .optional() 141 | .describe('Filter by work item states (New, Active, Closed, etc.)'), 142 | 'System.AssignedTo': z 143 | .array(z.string()) 144 | .optional() 145 | .describe('Filter by assigned users'), 146 | 'System.AreaPath': z 147 | .array(z.string()) 148 | .optional() 149 | .describe('Filter by area paths'), 150 | }) 151 | .optional() 152 | .describe('Optional filters to narrow search results'), 153 | top: z 154 | .number() 155 | .int() 156 | .min(1) 157 | .max(1000) 158 | .default(100) 159 | .describe('Number of results to return (default: 100, max: 1000)'), 160 | skip: z 161 | .number() 162 | .int() 163 | .min(0) 164 | .default(0) 165 | .describe('Number of results to skip for pagination (default: 0)'), 166 | includeFacets: z 167 | .boolean() 168 | .default(true) 169 | .describe('Whether to include faceting in results (default: true)'), 170 | orderBy: z 171 | .array( 172 | z.object({ 173 | field: z.string().describe('Field to sort by'), 174 | sortOrder: z.enum(['ASC', 'DESC']).describe('Sort order (ASC/DESC)'), 175 | }), 176 | ) 177 | .optional() 178 | .describe('Options for sorting search results'), 179 | }); 180 | ``` -------------------------------------------------------------------------------- /src/features/search/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { isSearchRequest, handleSearchRequest } from './index'; 4 | import { searchCode } from './search-code'; 5 | import { searchWiki } from './search-wiki'; 6 | import { searchWorkItems } from './search-work-items'; 7 | 8 | // Mock the imported modules 9 | jest.mock('./search-code', () => ({ 10 | searchCode: jest.fn(), 11 | })); 12 | 13 | jest.mock('./search-wiki', () => ({ 14 | searchWiki: jest.fn(), 15 | })); 16 | 17 | jest.mock('./search-work-items', () => ({ 18 | searchWorkItems: jest.fn(), 19 | })); 20 | 21 | describe('Search Request Handlers', () => { 22 | const mockConnection = {} as WebApi; 23 | 24 | describe('isSearchRequest', () => { 25 | it('should return true for search requests', () => { 26 | const validTools = ['search_code', 'search_wiki', 'search_work_items']; 27 | validTools.forEach((tool) => { 28 | const request = { 29 | params: { name: tool, arguments: {} }, 30 | method: 'tools/call', 31 | } as CallToolRequest; 32 | expect(isSearchRequest(request)).toBe(true); 33 | }); 34 | }); 35 | 36 | it('should return false for non-search requests', () => { 37 | const request = { 38 | params: { name: 'list_projects', arguments: {} }, 39 | method: 'tools/call', 40 | } as CallToolRequest; 41 | expect(isSearchRequest(request)).toBe(false); 42 | }); 43 | }); 44 | 45 | describe('handleSearchRequest', () => { 46 | it('should handle search_code request', async () => { 47 | const mockSearchResults = { 48 | count: 2, 49 | results: [ 50 | { fileName: 'file1.ts', path: '/path/to/file1.ts' }, 51 | { fileName: 'file2.ts', path: '/path/to/file2.ts' }, 52 | ], 53 | }; 54 | (searchCode as jest.Mock).mockResolvedValue(mockSearchResults); 55 | 56 | const request = { 57 | params: { 58 | name: 'search_code', 59 | arguments: { 60 | searchText: 'function', 61 | projectId: 'project1', 62 | }, 63 | }, 64 | method: 'tools/call', 65 | } as CallToolRequest; 66 | 67 | const response = await handleSearchRequest(mockConnection, request); 68 | expect(response.content).toHaveLength(1); 69 | expect(JSON.parse(response.content[0].text as string)).toEqual( 70 | mockSearchResults, 71 | ); 72 | expect(searchCode).toHaveBeenCalledWith( 73 | mockConnection, 74 | expect.objectContaining({ 75 | searchText: 'function', 76 | projectId: 'project1', 77 | }), 78 | ); 79 | }); 80 | 81 | it('should handle search_wiki request', async () => { 82 | const mockSearchResults = { 83 | count: 1, 84 | results: [{ title: 'Wiki Page', path: '/path/to/page' }], 85 | }; 86 | (searchWiki as jest.Mock).mockResolvedValue(mockSearchResults); 87 | 88 | const request = { 89 | params: { 90 | name: 'search_wiki', 91 | arguments: { 92 | searchText: 'documentation', 93 | projectId: 'project1', 94 | }, 95 | }, 96 | method: 'tools/call', 97 | } as CallToolRequest; 98 | 99 | const response = await handleSearchRequest(mockConnection, request); 100 | expect(response.content).toHaveLength(1); 101 | expect(JSON.parse(response.content[0].text as string)).toEqual( 102 | mockSearchResults, 103 | ); 104 | expect(searchWiki).toHaveBeenCalledWith( 105 | mockConnection, 106 | expect.objectContaining({ 107 | searchText: 'documentation', 108 | projectId: 'project1', 109 | }), 110 | ); 111 | }); 112 | 113 | it('should handle search_work_items request', async () => { 114 | const mockSearchResults = { 115 | count: 2, 116 | results: [ 117 | { id: 1, title: 'Bug 1' }, 118 | { id: 2, title: 'Feature 2' }, 119 | ], 120 | }; 121 | (searchWorkItems as jest.Mock).mockResolvedValue(mockSearchResults); 122 | 123 | const request = { 124 | params: { 125 | name: 'search_work_items', 126 | arguments: { 127 | searchText: 'bug', 128 | projectId: 'project1', 129 | }, 130 | }, 131 | method: 'tools/call', 132 | } as CallToolRequest; 133 | 134 | const response = await handleSearchRequest(mockConnection, request); 135 | expect(response.content).toHaveLength(1); 136 | expect(JSON.parse(response.content[0].text as string)).toEqual( 137 | mockSearchResults, 138 | ); 139 | expect(searchWorkItems).toHaveBeenCalledWith( 140 | mockConnection, 141 | expect.objectContaining({ 142 | searchText: 'bug', 143 | projectId: 'project1', 144 | }), 145 | ); 146 | }); 147 | 148 | it('should throw error for unknown tool', async () => { 149 | const request = { 150 | params: { 151 | name: 'unknown_tool', 152 | arguments: {}, 153 | }, 154 | method: 'tools/call', 155 | } as CallToolRequest; 156 | 157 | await expect( 158 | handleSearchRequest(mockConnection, request), 159 | ).rejects.toThrow('Unknown search tool'); 160 | }); 161 | 162 | it('should propagate errors from search functions', async () => { 163 | const mockError = new Error('Test error'); 164 | (searchCode as jest.Mock).mockRejectedValue(mockError); 165 | 166 | const request = { 167 | params: { 168 | name: 'search_code', 169 | arguments: { 170 | searchText: 'function', 171 | }, 172 | }, 173 | method: 'tools/call', 174 | } as CallToolRequest; 175 | 176 | await expect( 177 | handleSearchRequest(mockConnection, request), 178 | ).rejects.toThrow(mockError); 179 | }); 180 | }); 181 | }); 182 | ``` -------------------------------------------------------------------------------- /src/shared/auth/auth-factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; 2 | import { BearerCredentialHandler } from 'azure-devops-node-api/handlers/bearertoken'; 3 | import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; 4 | import { AzureDevOpsAuthenticationError } from '../errors'; 5 | 6 | /** 7 | * Authentication methods supported by the Azure DevOps client 8 | */ 9 | export enum AuthenticationMethod { 10 | /** 11 | * Personal Access Token authentication 12 | */ 13 | PersonalAccessToken = 'pat', 14 | 15 | /** 16 | * Azure Identity authentication (DefaultAzureCredential) 17 | */ 18 | AzureIdentity = 'azure-identity', 19 | 20 | /** 21 | * Azure CLI authentication (AzureCliCredential) 22 | */ 23 | AzureCli = 'azure-cli', 24 | } 25 | 26 | /** 27 | * Authentication configuration for Azure DevOps 28 | */ 29 | export interface AuthConfig { 30 | /** 31 | * Authentication method to use 32 | */ 33 | method: AuthenticationMethod; 34 | 35 | /** 36 | * Organization URL (e.g., https://dev.azure.com/myorg) 37 | */ 38 | organizationUrl: string; 39 | 40 | /** 41 | * Personal Access Token for Azure DevOps (required for PAT authentication) 42 | */ 43 | personalAccessToken?: string; 44 | } 45 | 46 | /** 47 | * Azure DevOps resource ID for token acquisition 48 | */ 49 | const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; 50 | 51 | /** 52 | * Creates an authenticated client for Azure DevOps API based on the specified authentication method 53 | * 54 | * @param config Authentication configuration 55 | * @returns Authenticated WebApi client 56 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 57 | */ 58 | export async function createAuthClient(config: AuthConfig): Promise<WebApi> { 59 | if (!config.organizationUrl) { 60 | throw new AzureDevOpsAuthenticationError('Organization URL is required'); 61 | } 62 | 63 | try { 64 | let client: WebApi; 65 | 66 | switch (config.method) { 67 | case AuthenticationMethod.PersonalAccessToken: 68 | client = await createPatClient(config); 69 | break; 70 | case AuthenticationMethod.AzureIdentity: 71 | client = await createAzureIdentityClient(config); 72 | break; 73 | case AuthenticationMethod.AzureCli: 74 | client = await createAzureCliClient(config); 75 | break; 76 | default: 77 | throw new AzureDevOpsAuthenticationError( 78 | `Unsupported authentication method: ${config.method}`, 79 | ); 80 | } 81 | 82 | // Test the connection 83 | const locationsApi = await client.getLocationsApi(); 84 | await locationsApi.getResourceAreas(); 85 | 86 | return client; 87 | } catch (error) { 88 | if (error instanceof AzureDevOpsAuthenticationError) { 89 | throw error; 90 | } 91 | throw new AzureDevOpsAuthenticationError( 92 | `Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}`, 93 | ); 94 | } 95 | } 96 | 97 | /** 98 | * Creates a client using Personal Access Token authentication 99 | * 100 | * @param config Authentication configuration 101 | * @returns Authenticated WebApi client 102 | * @throws {AzureDevOpsAuthenticationError} If PAT is missing or authentication fails 103 | */ 104 | async function createPatClient(config: AuthConfig): Promise<WebApi> { 105 | if (!config.personalAccessToken) { 106 | throw new AzureDevOpsAuthenticationError( 107 | 'Personal Access Token is required', 108 | ); 109 | } 110 | 111 | // Create authentication handler using PAT 112 | const authHandler = getPersonalAccessTokenHandler(config.personalAccessToken); 113 | 114 | // Create API client with the auth handler 115 | return new WebApi(config.organizationUrl, authHandler); 116 | } 117 | 118 | /** 119 | * Creates a client using DefaultAzureCredential authentication 120 | * 121 | * @param config Authentication configuration 122 | * @returns Authenticated WebApi client 123 | * @throws {AzureDevOpsAuthenticationError} If token acquisition fails 124 | */ 125 | async function createAzureIdentityClient(config: AuthConfig): Promise<WebApi> { 126 | try { 127 | // Create DefaultAzureCredential 128 | const credential = new DefaultAzureCredential(); 129 | 130 | // Get token for Azure DevOps 131 | const token = await credential.getToken( 132 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 133 | ); 134 | 135 | if (!token || !token.token) { 136 | throw new Error('Failed to acquire token'); 137 | } 138 | 139 | // Create bearer token handler 140 | const authHandler = new BearerCredentialHandler(token.token); 141 | 142 | // Create API client with the auth handler 143 | return new WebApi(config.organizationUrl, authHandler); 144 | } catch (error) { 145 | throw new AzureDevOpsAuthenticationError( 146 | `Failed to acquire Azure Identity token: ${error instanceof Error ? error.message : String(error)}`, 147 | ); 148 | } 149 | } 150 | 151 | /** 152 | * Creates a client using AzureCliCredential authentication 153 | * 154 | * @param config Authentication configuration 155 | * @returns Authenticated WebApi client 156 | * @throws {AzureDevOpsAuthenticationError} If token acquisition fails 157 | */ 158 | async function createAzureCliClient(config: AuthConfig): Promise<WebApi> { 159 | try { 160 | // Create AzureCliCredential 161 | const credential = new AzureCliCredential(); 162 | 163 | // Get token for Azure DevOps 164 | const token = await credential.getToken( 165 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 166 | ); 167 | 168 | if (!token || !token.token) { 169 | throw new Error('Failed to acquire token'); 170 | } 171 | 172 | // Create bearer token handler 173 | const authHandler = new BearerCredentialHandler(token.token); 174 | 175 | // Create API client with the auth handler 176 | return new WebApi(config.organizationUrl, authHandler); 177 | } catch (error) { 178 | throw new AzureDevOpsAuthenticationError( 179 | `Failed to acquire Azure CLI token: ${error instanceof Error ? error.message : String(error)}`, 180 | ); 181 | } 182 | } 183 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/list-pull-requests/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { PullRequest } from '../types'; 3 | import { listPullRequests } from './feature'; 4 | import { createPullRequest } from '../create-pull-request/feature'; 5 | 6 | import { 7 | getTestConnection, 8 | shouldSkipIntegrationTest, 9 | } from '../../../shared/test/test-helpers'; 10 | 11 | describe('listPullRequests integration', () => { 12 | let connection: WebApi | null = null; 13 | let testPullRequest: PullRequest | null = null; 14 | let projectName: string; 15 | let repositoryName: string; 16 | 17 | // Generate unique branch name and PR title using timestamp 18 | const timestamp = Date.now(); 19 | const randomSuffix = Math.floor(Math.random() * 1000); 20 | const uniqueBranchName = `test-branch-${timestamp}-${randomSuffix}`; 21 | const uniqueTitle = `Test PR ${timestamp}-${randomSuffix}`; 22 | 23 | beforeAll(async () => { 24 | // Get a real connection using environment variables 25 | connection = await getTestConnection(); 26 | 27 | // Set up project and repository names from environment 28 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 29 | repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || ''; 30 | 31 | // Skip setup if integration tests should be skipped 32 | if (shouldSkipIntegrationTest() || !connection) { 33 | return; 34 | } 35 | }); 36 | 37 | afterAll(async () => { 38 | // Clean up created resources if needed 39 | if ( 40 | testPullRequest && 41 | testPullRequest.pullRequestId && 42 | !shouldSkipIntegrationTest() 43 | ) { 44 | try { 45 | // Abandon the test pull request if it was created 46 | const gitApi = await connection?.getGitApi(); 47 | if (gitApi) { 48 | await gitApi.updatePullRequest( 49 | { 50 | status: 2, // 2 = Abandoned 51 | }, 52 | repositoryName, 53 | testPullRequest.pullRequestId, 54 | projectName, 55 | ); 56 | } 57 | } catch (error) { 58 | console.error('Error cleaning up test pull request:', error); 59 | } 60 | } 61 | }); 62 | 63 | test('should list pull requests from repository', async () => { 64 | // Skip if integration tests should be skipped 65 | if (shouldSkipIntegrationTest() || !connection) { 66 | console.log('Skipping test due to missing connection'); 67 | return; 68 | } 69 | 70 | // Skip if repository name is not defined 71 | if (!repositoryName) { 72 | console.log('Skipping test due to missing repository name'); 73 | return; 74 | } 75 | 76 | try { 77 | // Create a branch for testing 78 | const gitApi = await connection.getGitApi(); 79 | 80 | // Get the default branch info 81 | const repository = await gitApi.getRepository( 82 | repositoryName, 83 | projectName, 84 | ); 85 | 86 | if (!repository || !repository.defaultBranch) { 87 | throw new Error('Cannot find repository or default branch'); 88 | } 89 | 90 | // Get the commit to branch from 91 | const commits = await gitApi.getCommits( 92 | repositoryName, 93 | { 94 | itemVersion: { 95 | versionType: 0, // commit 96 | version: repository.defaultBranch.replace('refs/heads/', ''), 97 | }, 98 | $top: 1, 99 | }, 100 | projectName, 101 | ); 102 | 103 | if (!commits || commits.length === 0) { 104 | throw new Error('Cannot find commits in repository'); 105 | } 106 | 107 | // Create a new branch 108 | const refUpdate = { 109 | name: `refs/heads/${uniqueBranchName}`, 110 | oldObjectId: '0000000000000000000000000000000000000000', 111 | newObjectId: commits[0].commitId, 112 | }; 113 | 114 | const updateResult = await gitApi.updateRefs( 115 | [refUpdate], 116 | repositoryName, 117 | projectName, 118 | ); 119 | 120 | if ( 121 | !updateResult || 122 | updateResult.length === 0 || 123 | !updateResult[0].success 124 | ) { 125 | throw new Error('Failed to create new branch'); 126 | } 127 | 128 | // Create a test pull request 129 | testPullRequest = await createPullRequest( 130 | connection, 131 | projectName, 132 | repositoryName, 133 | { 134 | title: uniqueTitle, 135 | description: 'Test pull request for integration testing', 136 | sourceRefName: `refs/heads/${uniqueBranchName}`, 137 | targetRefName: repository.defaultBranch, 138 | isDraft: true, 139 | }, 140 | ); 141 | 142 | // List pull requests 143 | const pullRequests = await listPullRequests( 144 | connection, 145 | projectName, 146 | repositoryName, 147 | { projectId: projectName, repositoryId: repositoryName }, 148 | ); 149 | 150 | // Verify 151 | expect(pullRequests).toBeDefined(); 152 | expect(pullRequests.value).toBeDefined(); 153 | expect(Array.isArray(pullRequests.value)).toBe(true); 154 | expect(typeof pullRequests.count).toBe('number'); 155 | expect(typeof pullRequests.hasMoreResults).toBe('boolean'); 156 | 157 | // Find our test PR in the list 158 | const foundPR = pullRequests.value.find( 159 | (pr) => pr.pullRequestId === testPullRequest?.pullRequestId, 160 | ); 161 | expect(foundPR).toBeDefined(); 162 | expect(foundPR?.title).toBe(uniqueTitle); 163 | 164 | // Test with filters 165 | const filteredPRs = await listPullRequests( 166 | connection, 167 | projectName, 168 | repositoryName, 169 | { 170 | projectId: projectName, 171 | repositoryId: repositoryName, 172 | status: 'active', 173 | top: 5, 174 | }, 175 | ); 176 | 177 | expect(filteredPRs).toBeDefined(); 178 | expect(filteredPRs.value).toBeDefined(); 179 | expect(Array.isArray(filteredPRs.value)).toBe(true); 180 | expect(filteredPRs.count).toBeGreaterThanOrEqual(0); 181 | } catch (error) { 182 | console.error('Test error:', error); 183 | throw error; 184 | } 185 | }, 30000); // 30 second timeout for integration test 186 | }); 187 | ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios from 'axios'; 3 | import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; 4 | import { 5 | AzureDevOpsError, 6 | AzureDevOpsResourceNotFoundError, 7 | AzureDevOpsValidationError, 8 | AzureDevOpsPermissionError, 9 | } from '../../../shared/errors'; 10 | import { 11 | SearchWorkItemsOptions, 12 | WorkItemSearchRequest, 13 | WorkItemSearchResponse, 14 | } from '../types'; 15 | 16 | /** 17 | * Search for work items in Azure DevOps projects 18 | * 19 | * @param connection The Azure DevOps WebApi connection 20 | * @param options Parameters for searching work items 21 | * @returns Search results with work item details and highlights 22 | */ 23 | export async function searchWorkItems( 24 | connection: WebApi, 25 | options: SearchWorkItemsOptions, 26 | ): Promise<WorkItemSearchResponse> { 27 | try { 28 | // Prepare the search request 29 | const searchRequest: WorkItemSearchRequest = { 30 | searchText: options.searchText, 31 | $skip: options.skip, 32 | $top: options.top, 33 | filters: { 34 | ...(options.projectId 35 | ? { 'System.TeamProject': [options.projectId] } 36 | : {}), 37 | ...options.filters, 38 | }, 39 | includeFacets: options.includeFacets, 40 | $orderBy: options.orderBy, 41 | }; 42 | 43 | // Get the authorization header from the connection 44 | const authHeader = await getAuthorizationHeader(); 45 | 46 | // Extract organization and project from the connection URL 47 | const { organization, project } = extractOrgAndProject( 48 | connection, 49 | options.projectId, 50 | ); 51 | 52 | // Make the search API request 53 | // If projectId is provided, include it in the URL, otherwise perform organization-wide search 54 | const searchUrl = options.projectId 55 | ? `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/workitemsearchresults?api-version=7.1` 56 | : `https://almsearch.dev.azure.com/${organization}/_apis/search/workitemsearchresults?api-version=7.1`; 57 | 58 | const searchResponse = await axios.post<WorkItemSearchResponse>( 59 | searchUrl, 60 | searchRequest, 61 | { 62 | headers: { 63 | Authorization: authHeader, 64 | 'Content-Type': 'application/json', 65 | }, 66 | }, 67 | ); 68 | 69 | return searchResponse.data; 70 | } catch (error) { 71 | // If it's already an AzureDevOpsError, rethrow it 72 | if (error instanceof AzureDevOpsError) { 73 | throw error; 74 | } 75 | 76 | // Handle axios errors 77 | if (axios.isAxiosError(error)) { 78 | const status = error.response?.status; 79 | const message = error.response?.data?.message || error.message; 80 | 81 | if (status === 404) { 82 | throw new AzureDevOpsResourceNotFoundError( 83 | `Resource not found: ${message}`, 84 | ); 85 | } else if (status === 400) { 86 | throw new AzureDevOpsValidationError( 87 | `Invalid request: ${message}`, 88 | error.response?.data, 89 | ); 90 | } else if (status === 401 || status === 403) { 91 | throw new AzureDevOpsPermissionError(`Permission denied: ${message}`); 92 | } else { 93 | // For other axios errors, wrap in a generic AzureDevOpsError 94 | throw new AzureDevOpsError(`Azure DevOps API error: ${message}`); 95 | } 96 | // This code is unreachable but TypeScript doesn't know that 97 | } 98 | 99 | // Otherwise, wrap it in a generic error 100 | throw new AzureDevOpsError( 101 | `Failed to search work items: ${error instanceof Error ? error.message : String(error)}`, 102 | ); 103 | } 104 | } 105 | 106 | /** 107 | * Extract organization and project from the connection URL 108 | * 109 | * @param connection The Azure DevOps WebApi connection 110 | * @param projectId The project ID or name (optional) 111 | * @returns The organization and project 112 | */ 113 | function extractOrgAndProject( 114 | connection: WebApi, 115 | projectId?: string, 116 | ): { organization: string; project: string } { 117 | // Extract organization from the connection URL 118 | const url = connection.serverUrl; 119 | const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); 120 | const organization = match ? match[1] : ''; 121 | 122 | if (!organization) { 123 | throw new AzureDevOpsValidationError( 124 | 'Could not extract organization from connection URL', 125 | ); 126 | } 127 | 128 | return { 129 | organization, 130 | project: projectId || '', 131 | }; 132 | } 133 | 134 | /** 135 | * Get the authorization header from the connection 136 | * 137 | * @returns The authorization header 138 | */ 139 | async function getAuthorizationHeader(): Promise<string> { 140 | try { 141 | // For PAT authentication, we can construct the header directly 142 | if ( 143 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && 144 | process.env.AZURE_DEVOPS_PAT 145 | ) { 146 | // For PAT auth, we can construct the Basic auth header directly 147 | const token = process.env.AZURE_DEVOPS_PAT; 148 | const base64Token = Buffer.from(`:${token}`).toString('base64'); 149 | return `Basic ${base64Token}`; 150 | } 151 | 152 | // For Azure Identity / Azure CLI auth, we need to get a token 153 | // using the Azure DevOps resource ID 154 | // Choose the appropriate credential based on auth method 155 | const credential = 156 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' 157 | ? new AzureCliCredential() 158 | : new DefaultAzureCredential(); 159 | 160 | // Azure DevOps resource ID for token acquisition 161 | const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; 162 | 163 | // Get token for Azure DevOps 164 | const token = await credential.getToken( 165 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 166 | ); 167 | 168 | if (!token || !token.token) { 169 | throw new Error('Failed to acquire token for Azure DevOps'); 170 | } 171 | 172 | return `Bearer ${token.token}`; 173 | } catch (error) { 174 | throw new AzureDevOpsValidationError( 175 | `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, 176 | ); 177 | } 178 | } 179 | ``` -------------------------------------------------------------------------------- /docs/tools/wiki.md: -------------------------------------------------------------------------------- ```markdown 1 | # Azure DevOps Wiki Tools 2 | 3 | This document describes the tools available for working with Azure DevOps wikis. 4 | 5 | ## get_wikis 6 | 7 | Lists all wikis in a project or organization. 8 | 9 | ### Description 10 | 11 | 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. 12 | 13 | ### Parameters 14 | 15 | - `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used. 16 | - `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used. 17 | 18 | ```json 19 | { 20 | "organizationId": "MyOrganization", 21 | "projectId": "MyProject" 22 | } 23 | ``` 24 | 25 | ### Response 26 | 27 | The tool returns an array of wiki objects, each containing: 28 | 29 | - `id`: The unique identifier of the wiki 30 | - `name`: The name of the wiki 31 | - `url`: The URL of the wiki 32 | - Other wiki properties such as `remoteUrl` and `type` 33 | 34 | Example response: 35 | 36 | ```json 37 | [ 38 | { 39 | "id": "wiki1-id", 40 | "name": "MyWiki", 41 | "type": "projectWiki", 42 | "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki", 43 | "remoteUrl": "https://dev.azure.com/MyOrganization/MyProject/_git/MyWiki" 44 | } 45 | ] 46 | ``` 47 | 48 | ## get_wiki_page 49 | 50 | Gets the content of a specific wiki page. 51 | 52 | ### Description 53 | 54 | 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. 55 | 56 | ### Parameters 57 | 58 | - `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used. 59 | - `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used. 60 | - `wikiId` (required): The ID or name of the wiki containing the page. 61 | - `pagePath` (required): The path of the page within the wiki (e.g., "/Home" or "/Folder/Page"). 62 | 63 | ```json 64 | { 65 | "organizationId": "MyOrganization", 66 | "projectId": "MyProject", 67 | "wikiId": "MyWiki", 68 | "pagePath": "/Home" 69 | } 70 | ``` 71 | 72 | ### Response 73 | 74 | The tool returns the content of the wiki page as a string in markdown format. 75 | 76 | Example response: 77 | 78 | ```markdown 79 | # Welcome to the Wiki 80 | 81 | This is the home page of the wiki. 82 | 83 | ## Getting Started 84 | 85 | Here are some links to help you get started: 86 | - [Documentation](/Documentation) 87 | - [Tutorials](/Tutorials) 88 | - [FAQ](/FAQ) 89 | ``` 90 | 91 | ### Error Handling 92 | 93 | The tool may throw the following errors: 94 | 95 | - `AzureDevOpsResourceNotFoundError`: If the specified wiki or page does not exist 96 | - `AzureDevOpsPermissionError`: If the authenticated user does not have permission to access the wiki 97 | - General errors: If other unexpected errors occur during the request 98 | 99 | ### Example Usage 100 | 101 | ```typescript 102 | // Example MCP client call 103 | const result = await mcpClient.callTool('get_wiki_page', { 104 | projectId: 'MyProject', 105 | wikiId: 'MyWiki', 106 | pagePath: '/Home' 107 | }); 108 | console.log(result); 109 | ``` 110 | 111 | ### Implementation Details 112 | 113 | 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. 114 | 115 | ## list_wiki_pages 116 | 117 | Lists all pages within a specified Azure DevOps wiki. 118 | 119 | ### Description 120 | 121 | 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. 122 | 123 | ### Parameters 124 | 125 | - `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used. 126 | - `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used. 127 | - `wikiId` (required): The ID or name of the wiki to list pages from. 128 | 129 | ```json 130 | { 131 | "organizationId": "MyOrganization", 132 | "projectId": "MyProject", 133 | "wikiId": "MyWiki" 134 | } 135 | ``` 136 | 137 | ### Response 138 | 139 | The tool returns an array of wiki page summary objects, each containing: 140 | 141 | - `id`: The unique numeric identifier of the page 142 | - `path`: The path of the page within the wiki (e.g., "/Home" or "/Folder/Page") 143 | - `url`: The URL to access the page (optional) 144 | - `order`: The display order of the page (optional) 145 | 146 | Example response: 147 | 148 | ```json 149 | [ 150 | { 151 | "id": 1, 152 | "path": "/Home", 153 | "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/1/Home", 154 | "order": 0 155 | }, 156 | { 157 | "id": 2, 158 | "path": "/Documentation", 159 | "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/2/Documentation", 160 | "order": 1 161 | }, 162 | { 163 | "id": 3, 164 | "path": "/Documentation/Getting-Started", 165 | "url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/3/Getting-Started", 166 | "order": 2 167 | } 168 | ] 169 | ``` 170 | 171 | ### Error Handling 172 | 173 | The tool may throw the following errors: 174 | 175 | - `AzureDevOpsResourceNotFoundError`: If the specified wiki does not exist 176 | - `AzureDevOpsPermissionError`: If the authenticated user does not have permission to access the wiki 177 | - `AzureDevOpsError`: If other unexpected errors occur during the request 178 | 179 | ### Example Usage 180 | 181 | ```typescript 182 | // Example MCP client call 183 | const result = await mcpClient.callTool('list_wiki_pages', { 184 | projectId: 'MyProject', 185 | wikiId: 'MyWiki' 186 | }); 187 | console.log(result); 188 | ``` 189 | 190 | ### Implementation Details 191 | 192 | 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. ```