@mcp-consultant-tools/azure-devops 26.0.0-beta.1 → 27.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/build/AzureDevOpsService.d.ts +179 -514
  2. package/build/AzureDevOpsService.d.ts.map +1 -1
  3. package/build/AzureDevOpsService.js +401 -994
  4. package/build/AzureDevOpsService.js.map +1 -1
  5. package/build/index.d.ts.map +1 -1
  6. package/build/index.js +1038 -844
  7. package/build/index.js.map +1 -1
  8. package/build/sync/file-utils.d.ts +86 -0
  9. package/build/sync/file-utils.d.ts.map +1 -0
  10. package/build/sync/file-utils.js +224 -0
  11. package/build/sync/file-utils.js.map +1 -0
  12. package/build/sync/git-utils.d.ts +31 -0
  13. package/build/sync/git-utils.d.ts.map +1 -0
  14. package/build/sync/git-utils.js +116 -0
  15. package/build/sync/git-utils.js.map +1 -0
  16. package/build/sync/html-converter.d.ts +32 -0
  17. package/build/sync/html-converter.d.ts.map +1 -0
  18. package/build/sync/html-converter.js +91 -0
  19. package/build/sync/html-converter.js.map +1 -0
  20. package/build/sync/html-detection.d.ts +93 -0
  21. package/build/sync/html-detection.d.ts.map +1 -0
  22. package/build/sync/html-detection.js +169 -0
  23. package/build/sync/html-detection.js.map +1 -0
  24. package/build/sync/index.d.ts +12 -0
  25. package/build/sync/index.d.ts.map +1 -0
  26. package/build/sync/index.js +12 -0
  27. package/build/sync/index.js.map +1 -0
  28. package/build/sync/markdown-serializer.d.ts +136 -0
  29. package/build/sync/markdown-serializer.d.ts.map +1 -0
  30. package/build/sync/markdown-serializer.js +646 -0
  31. package/build/sync/markdown-serializer.js.map +1 -0
  32. package/build/sync/task-serializer.d.ts +93 -0
  33. package/build/sync/task-serializer.d.ts.map +1 -0
  34. package/build/sync/task-serializer.js +395 -0
  35. package/build/sync/task-serializer.js.map +1 -0
  36. package/build/tool-examples.d.ts +56 -0
  37. package/build/tool-examples.d.ts.map +1 -0
  38. package/build/tool-examples.js +142 -0
  39. package/build/tool-examples.js.map +1 -0
  40. package/package.json +5 -2
package/build/index.js CHANGED
@@ -10,6 +10,14 @@ import { realpathSync } from "node:fs";
10
10
  import { createMcpServer, createEnvLoader } from "@mcp-consultant-tools/core";
11
11
  import { AzureDevOpsService } from "./AzureDevOpsService.js";
12
12
  import { z } from 'zod';
13
+ import { descWithExamples, WIQL_EXAMPLES, PATCH_OP_EXAMPLES, WORK_ITEM_FIELD_EXAMPLES, SYNC_TO_FILE_EXAMPLES, SYNC_FROM_FILE_EXAMPLES, SYNC_TASKS_TO_FILE_EXAMPLES, SYNC_TASKS_FROM_FILE_EXAMPLES, PR_BRANCH_REF_EXAMPLES, PR_MERGE_STRATEGY_EXAMPLES, PR_VOTE_EXAMPLES, } from './tool-examples.js';
14
+ import { checkFieldFormats, workItemToMarkdown, commentsToMarkdown, buildPatchOperations, updateSyncRevision, getSyncConfig, ensureFolderExists, getWorkItemFilePath, getCommentsFilePath, fileExists, writeWorkItemFile, readWorkItemFile, readFileContent, listSyncedWorkItems, validateFolderPath, autoCommitMultipleFiles,
15
+ // Task sync utilities
16
+ tasksToMarkdown, parseTasksMarkdown, buildTaskPatchOperations, buildNewTaskFields, getTasksFilePath, updateTasksFileAfterCreate,
17
+ // New work item utilities
18
+ parseNewWorkItemMarkdown, buildNewWorkItemFields, generateNewWorkItemTemplate, convertNewFileToSynced, getNewWorkItemFilePath, findNextNewFileIndex, findNewWorkItemFiles, renameFile,
19
+ // Markdown format utilities
20
+ getAllLargeTextFields, autoConvertFieldsToMarkdown, isHtmlContent, } from './sync/index.js';
13
21
  /**
14
22
  * Register azure-devops tools and prompts to an MCP server
15
23
  * @param server - The MCP server instance
@@ -38,23 +46,9 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
38
46
  enableWorkItemWrite: process.env.AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE === "true",
39
47
  enableWorkItemDelete: process.env.AZUREDEVOPS_ENABLE_WORK_ITEM_DELETE === "true",
40
48
  enableWikiWrite: process.env.AZUREDEVOPS_ENABLE_WIKI_WRITE === "true",
49
+ enablePullRequestWrite: process.env.AZUREDEVOPS_ENABLE_PR_WRITE === "true",
41
50
  // Comment format: 'markdown' (default) or 'html' (for legacy orgs without Markdown preview)
42
51
  commentFormat: process.env.AZUREDEVOPS_COMMENT_FORMAT || 'markdown',
43
- // DevOps Admin Tools - Three-tier permission model
44
- // Tier 1: Read-only (master switch)
45
- showDevOpsAdminReadonly: process.env.AZUREDEVOPS_SHOW_DEVOPS_ADMIN_READONLY === "true",
46
- // Tier 2: Upsert (create + update)
47
- enablePipelineUpsert: process.env.AZUREDEVOPS_ENABLE_PIPELINE_UPSERT === "true",
48
- enableServiceConnUpsert: process.env.AZUREDEVOPS_ENABLE_SERVICE_CONN_UPSERT === "true",
49
- enableVariableGroupUpsert: process.env.AZUREDEVOPS_ENABLE_VARIABLE_GROUP_UPSERT === "true",
50
- enableAgentPoolUpsert: process.env.AZUREDEVOPS_ENABLE_AGENT_POOL_UPSERT === "true",
51
- enableEnvironmentUpsert: process.env.AZUREDEVOPS_ENABLE_ENVIRONMENT_UPSERT === "true",
52
- // Tier 3: Delete/Disable (destructive operations)
53
- enablePipelineDelete: process.env.AZUREDEVOPS_ENABLE_PIPELINE_DELETE === "true",
54
- enableServiceConnDelete: process.env.AZUREDEVOPS_ENABLE_SERVICE_CONN_DELETE === "true",
55
- enableVariableGroupDelete: process.env.AZUREDEVOPS_ENABLE_VARIABLE_GROUP_DELETE === "true",
56
- enableAgentPoolDisable: process.env.AZUREDEVOPS_ENABLE_AGENT_POOL_DISABLE === "true",
57
- enableEnvironmentDelete: process.env.AZUREDEVOPS_ENABLE_ENVIRONMENT_DELETE === "true",
58
52
  };
59
53
  service = new AzureDevOpsService(config);
60
54
  console.error("Azure DevOps service initialized");
@@ -330,6 +324,45 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
330
324
  // ========================================
331
325
  // TOOLS
332
326
  // ========================================
327
+ server.tool("get-configuration", "Get the configured Azure DevOps organization and projects. Use this to construct correct URLs.", {}, async () => {
328
+ try {
329
+ const organization = process.env.AZUREDEVOPS_ORGANIZATION;
330
+ const projects = process.env.AZUREDEVOPS_PROJECTS?.split(",").map(p => p.trim()).filter(p => p) || [];
331
+ const syncFolder = process.env.AZUREDEVOPS_SYNC_FOLDER || 'docs/user-stories';
332
+ if (!organization || projects.length === 0) {
333
+ return {
334
+ content: [{
335
+ type: "text",
336
+ text: "Azure DevOps not configured. Set AZUREDEVOPS_ORGANIZATION and AZUREDEVOPS_PROJECTS environment variables.",
337
+ }],
338
+ };
339
+ }
340
+ const config = {
341
+ organization,
342
+ projects,
343
+ syncFolder,
344
+ urlPatterns: {
345
+ workItem: `https://dev.azure.com/${organization}/{project}/_workitems/edit/{id}`,
346
+ pullRequest: `https://dev.azure.com/${organization}/{project}/_git/{repo}/pullrequest/{id}`,
347
+ wiki: `https://dev.azure.com/${organization}/{project}/_wiki/wikis/{wikiName}/{pagePath}`,
348
+ },
349
+ };
350
+ return {
351
+ content: [{
352
+ type: "text",
353
+ text: `Azure DevOps Configuration:\n\n${JSON.stringify(config, null, 2)}`,
354
+ }],
355
+ };
356
+ }
357
+ catch (error) {
358
+ return {
359
+ content: [{
360
+ type: "text",
361
+ text: `Failed to get configuration: ${error.message}`,
362
+ }],
363
+ };
364
+ }
365
+ });
333
366
  server.tool("get-wikis", "Get all wikis in an Azure DevOps project", {
334
367
  project: z.string().describe("The project name"),
335
368
  }, async ({ project }) => {
@@ -483,7 +516,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
483
516
  };
484
517
  }
485
518
  });
486
- server.tool("azuredevops-str-replace-wiki-page", "Replace a specific string in an Azure DevOps wiki page without rewriting entire content. More efficient than update-wiki-page for small changes. (requires AZUREDEVOPS_ENABLE_WIKI_WRITE=true)", {
519
+ server.tool("ado-str-replace-wiki", "Replace a specific string in an Azure DevOps wiki page without rewriting entire content. More efficient than update-wiki-page for small changes. (requires AZUREDEVOPS_ENABLE_WIKI_WRITE=true)", {
487
520
  project: z.string().describe("The project name"),
488
521
  wikiId: z.string().describe("The wiki identifier (ID or name)"),
489
522
  pagePath: z.string().describe("The path to the wiki page (e.g., '/SharePoint-Online/04-DEV-Configuration')"),
@@ -549,7 +582,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
549
582
  });
550
583
  server.tool("query-work-items", "Query work items using WIQL (Work Item Query Language) in Azure DevOps", {
551
584
  project: z.string().describe("The project name"),
552
- wiql: z.string().describe("The WIQL query string (e.g., \"SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.State] = 'Active'\")"),
585
+ wiql: z.string().describe(descWithExamples("The WIQL query string. SQL-like syntax with field names in brackets. Common fields: [System.Id], [System.Title], [System.State], [System.WorkItemType], [System.AssignedTo], [System.Parent]. Use @Me for current user.", WIQL_EXAMPLES)),
553
586
  maxResults: z.number().optional().describe("Maximum number of results (default: 200)"),
554
587
  }, async ({ project, wiql, maxResults }) => {
555
588
  try {
@@ -636,24 +669,53 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
636
669
  };
637
670
  }
638
671
  });
639
- server.tool("update-work-item", "Update a work item in Azure DevOps using JSON Patch operations (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true)", {
672
+ server.tool("update-work-item", "Update a work item in Azure DevOps using JSON Patch operations (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true). Auto-injects markdown format operations for large text fields.", {
640
673
  project: z.string().describe("The project name"),
641
674
  workItemId: z.number().describe("The work item ID"),
642
675
  patchOperations: z.array(z.object({
643
- op: z.string().describe("The operation type (e.g., 'add', 'replace', 'remove')"),
644
- path: z.string().describe("The field path (e.g., '/fields/System.State')"),
676
+ op: z.string().describe("The operation type: 'add' (set value), 'replace' (update existing), or 'remove' (clear field)"),
677
+ path: z.string().describe("The field path starting with '/fields/' (e.g., '/fields/System.State', '/fields/System.Title')"),
645
678
  value: z.any().optional().describe("The value to set (not required for 'remove' operation)")
646
- })).describe("Array of JSON Patch operations"),
647
- }, async ({ project, workItemId, patchOperations }) => {
679
+ })).describe(descWithExamples("Array of JSON Patch operations. Each operation specifies what to change.", PATCH_OP_EXAMPLES)),
680
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic markdown format injection for large text fields. Only use when explicitly requested. Default: false"),
681
+ }, async ({ project, workItemId, patchOperations, skipAutoConvert }) => {
648
682
  try {
649
683
  const service = getAzureDevOpsService();
650
- const result = await service.updateWorkItem(project, workItemId, patchOperations);
684
+ // Auto-inject markdown format operations for large text fields
685
+ let finalOperations = [...patchOperations];
686
+ const formatOpsAdded = [];
687
+ if (!skipAutoConvert) {
688
+ // Find large text fields being updated (includes custom fields)
689
+ const allLargeTextFields = getAllLargeTextFields();
690
+ const largeTextFieldsBeingUpdated = patchOperations
691
+ .filter((op) => op.path?.startsWith('/fields/'))
692
+ .map((op) => op.path.replace('/fields/', ''))
693
+ .filter((field) => allLargeTextFields.includes(field));
694
+ for (const field of largeTextFieldsBeingUpdated) {
695
+ // Check if format operation already exists
696
+ const hasFormatOp = finalOperations.some((op) => op.path === `/multilineFieldsFormat/${field}`);
697
+ if (!hasFormatOp) {
698
+ // Add format operation to ensure markdown
699
+ finalOperations.push({
700
+ op: 'add',
701
+ path: `/multilineFieldsFormat/${field}`,
702
+ value: 'Markdown'
703
+ });
704
+ formatOpsAdded.push(field);
705
+ }
706
+ }
707
+ }
708
+ const result = await service.updateWorkItem(project, workItemId, finalOperations);
709
+ let message = `Updated work item ${workItemId}`;
710
+ if (formatOpsAdded.length > 0) {
711
+ message += ` (auto-set markdown format for: ${formatOpsAdded.join(', ')})`;
712
+ }
651
713
  const resultStr = JSON.stringify(result, null, 2);
652
714
  return {
653
715
  content: [
654
716
  {
655
717
  type: "text",
656
- text: `Updated work item ${workItemId}:\n\n${resultStr}`,
718
+ text: `${message}:\n\n${resultStr}`,
657
719
  },
658
720
  ],
659
721
  };
@@ -672,8 +734,8 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
672
734
  });
673
735
  server.tool("create-work-item", "Create a new work item in Azure DevOps with optional parent relationship (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true)", {
674
736
  project: z.string().describe("The project name"),
675
- workItemType: z.string().describe("The work item type (e.g., 'Bug', 'Task', 'User Story')"),
676
- fields: z.record(z.any()).describe("Object with field values (e.g., {\"System.Title\": \"Bug title\", \"System.Description\": \"Details\"})"),
737
+ workItemType: z.string().describe("The work item type: 'Bug', 'Task', 'User Story', 'Feature', 'Epic', or custom types"),
738
+ fields: z.record(z.any()).describe(descWithExamples("Object with field values. Required: System.Title. Common fields: System.Description, System.State, System.AssignedTo, Microsoft.VSTS.Common.AcceptanceCriteria, Microsoft.VSTS.TCM.ReproSteps (bugs).", WORK_ITEM_FIELD_EXAMPLES)),
677
739
  parentId: z.number().optional().describe("Optional parent work item ID (for creating child items). Simplified alternative to relations parameter."),
678
740
  relations: z.array(z.object({
679
741
  rel: z.string().describe("Relation type (e.g., 'System.LinkTypes.Hierarchy-Reverse' for parent)"),
@@ -736,900 +798,1032 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
736
798
  }
737
799
  });
738
800
  // ========================================
739
- // VARIABLE GROUP TOOLS
801
+ // PULL REQUEST TOOLS (Read-only - always available)
740
802
  // ========================================
741
- server.tool("list-variable-groups", "List all variable groups in an Azure DevOps project. Variable groups store values and secrets used in pipelines.", {
803
+ server.tool("list-repositories", "List all Git repositories in an Azure DevOps project. Returns repository ID, name, default branch, and URLs.", {
742
804
  project: z.string().describe("The project name"),
743
805
  }, async ({ project }) => {
744
806
  try {
745
807
  const service = getAzureDevOpsService();
746
- const result = await service.getVariableGroups(project);
747
- const resultStr = JSON.stringify(result, null, 2);
748
- return {
749
- content: [
750
- {
751
- type: "text",
752
- text: `Variable groups in project '${project}':\n\n${resultStr}`,
753
- },
754
- ],
755
- };
808
+ const result = await service.listRepositories(project);
809
+ return { content: [{ type: "text", text: `Repositories in project '${project}':\n\n${JSON.stringify(result, null, 2)}` }] };
756
810
  }
757
811
  catch (error) {
758
- console.error("Error listing variable groups:", error);
759
- return {
760
- content: [
761
- {
762
- type: "text",
763
- text: `Failed to list variable groups: ${error.message}`,
764
- },
765
- ],
766
- };
812
+ console.error("Error listing repositories:", error);
813
+ return { content: [{ type: "text", text: `Failed to list repositories: ${error.message}` }] };
767
814
  }
768
815
  });
769
- server.tool("get-variable-group", "Get a specific variable group by ID from Azure DevOps. Returns all variables (secrets are masked).", {
816
+ server.tool("list-pull-requests", "List pull requests in a Git repository. Filter by status (active, completed, abandoned, all). Returns PR ID, title, author, branches, and review status.", {
770
817
  project: z.string().describe("The project name"),
771
- groupId: z.number().describe("The variable group ID"),
772
- }, async ({ project, groupId }) => {
818
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
819
+ status: z.enum(["active", "completed", "abandoned", "all"]).optional().describe("Filter by PR status (default: active)"),
820
+ top: z.number().optional().describe("Maximum results (default: 25)"),
821
+ }, async ({ project, repositoryId, status, top }) => {
773
822
  try {
774
823
  const service = getAzureDevOpsService();
775
- const result = await service.getVariableGroup(project, groupId);
776
- const resultStr = JSON.stringify(result, null, 2);
777
- return {
778
- content: [
779
- {
780
- type: "text",
781
- text: `Variable group ${groupId} in project '${project}':\n\n${resultStr}`,
782
- },
783
- ],
784
- };
824
+ const result = await service.listPullRequests(project, repositoryId, status || 'active', top || 25);
825
+ return { content: [{ type: "text", text: `Pull requests in '${repositoryId}':\n\n${JSON.stringify(result, null, 2)}` }] };
785
826
  }
786
827
  catch (error) {
787
- console.error("Error getting variable group:", error);
788
- return {
789
- content: [
790
- {
791
- type: "text",
792
- text: `Failed to get variable group: ${error.message}`,
793
- },
794
- ],
795
- };
828
+ console.error("Error listing pull requests:", error);
829
+ return { content: [{ type: "text", text: `Failed to list pull requests: ${error.message}` }] };
830
+ }
831
+ });
832
+ server.tool("get-pull-request", "Get details of a specific pull request including title, description, author, reviewers with votes, and merge status.", {
833
+ project: z.string().describe("The project name"),
834
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
835
+ pullRequestId: z.number().describe("The pull request ID"),
836
+ }, async ({ project, repositoryId, pullRequestId }) => {
837
+ try {
838
+ const service = getAzureDevOpsService();
839
+ const result = await service.getPullRequest(project, repositoryId, pullRequestId);
840
+ return { content: [{ type: "text", text: `Pull request #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
841
+ }
842
+ catch (error) {
843
+ console.error("Error getting pull request:", error);
844
+ return { content: [{ type: "text", text: `Failed to get pull request: ${error.message}` }] };
845
+ }
846
+ });
847
+ server.tool("get-pull-request-threads", "Get all comment threads and discussions on a pull request. Includes inline code comments with file paths and line numbers.", {
848
+ project: z.string().describe("The project name"),
849
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
850
+ pullRequestId: z.number().describe("The pull request ID"),
851
+ }, async ({ project, repositoryId, pullRequestId }) => {
852
+ try {
853
+ const service = getAzureDevOpsService();
854
+ const result = await service.getPullRequestThreads(project, repositoryId, pullRequestId);
855
+ return { content: [{ type: "text", text: `Threads for PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
856
+ }
857
+ catch (error) {
858
+ console.error("Error getting pull request threads:", error);
859
+ return { content: [{ type: "text", text: `Failed to get pull request threads: ${error.message}` }] };
860
+ }
861
+ });
862
+ server.tool("get-pull-request-commits", "Get all commits included in a pull request. Shows commit ID, message, author, and date.", {
863
+ project: z.string().describe("The project name"),
864
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
865
+ pullRequestId: z.number().describe("The pull request ID"),
866
+ }, async ({ project, repositoryId, pullRequestId }) => {
867
+ try {
868
+ const service = getAzureDevOpsService();
869
+ const result = await service.getPullRequestCommits(project, repositoryId, pullRequestId);
870
+ return { content: [{ type: "text", text: `Commits for PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
871
+ }
872
+ catch (error) {
873
+ console.error("Error getting pull request commits:", error);
874
+ return { content: [{ type: "text", text: `Failed to get pull request commits: ${error.message}` }] };
875
+ }
876
+ });
877
+ server.tool("get-pull-request-changes", "Get file changes (diffs) in a pull request. Shows added, modified, deleted, and renamed files with their paths.", {
878
+ project: z.string().describe("The project name"),
879
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
880
+ pullRequestId: z.number().describe("The pull request ID"),
881
+ iterationId: z.number().optional().describe("Iteration ID (default: latest)"),
882
+ }, async ({ project, repositoryId, pullRequestId, iterationId }) => {
883
+ try {
884
+ const service = getAzureDevOpsService();
885
+ const result = await service.getPullRequestChanges(project, repositoryId, pullRequestId, iterationId);
886
+ return { content: [{ type: "text", text: `Changes for PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
887
+ }
888
+ catch (error) {
889
+ console.error("Error getting pull request changes:", error);
890
+ return { content: [{ type: "text", text: `Failed to get pull request changes: ${error.message}` }] };
796
891
  }
797
892
  });
798
893
  // ========================================
799
- // DEVOPS ADMIN TOOLS - CONDITIONAL REGISTRATION
800
- // Tools are only registered when corresponding flags are enabled
801
- // ========================================
802
- // Helper to check if admin readonly is enabled (master switch)
803
- const showAdminReadonly = process.env.AZUREDEVOPS_SHOW_DEVOPS_ADMIN_READONLY === "true";
804
- // Tier 2: Upsert flags
805
- const enablePipelineUpsert = process.env.AZUREDEVOPS_ENABLE_PIPELINE_UPSERT === "true";
806
- const enableServiceConnUpsert = process.env.AZUREDEVOPS_ENABLE_SERVICE_CONN_UPSERT === "true";
807
- const enableVariableGroupUpsert = process.env.AZUREDEVOPS_ENABLE_VARIABLE_GROUP_UPSERT === "true";
808
- const enableAgentPoolUpsert = process.env.AZUREDEVOPS_ENABLE_AGENT_POOL_UPSERT === "true";
809
- const enableEnvironmentUpsert = process.env.AZUREDEVOPS_ENABLE_ENVIRONMENT_UPSERT === "true";
810
- // Tier 3: Delete/Disable flags
811
- const enablePipelineDelete = process.env.AZUREDEVOPS_ENABLE_PIPELINE_DELETE === "true";
812
- const enableServiceConnDelete = process.env.AZUREDEVOPS_ENABLE_SERVICE_CONN_DELETE === "true";
813
- const enableVariableGroupDelete = process.env.AZUREDEVOPS_ENABLE_VARIABLE_GROUP_DELETE === "true";
814
- const enableAgentPoolDisable = process.env.AZUREDEVOPS_ENABLE_AGENT_POOL_DISABLE === "true";
815
- const enableEnvironmentDelete = process.env.AZUREDEVOPS_ENABLE_ENVIRONMENT_DELETE === "true";
816
- // Track registered tools count
817
- let adminToolsCount = 0;
818
- // ========================================
819
- // PIPELINE READ-ONLY TOOLS (Tier 1)
820
- // ========================================
821
- if (showAdminReadonly) {
822
- server.tool("list-pipelines", "List all YAML pipeline definitions in an Azure DevOps project. Returns pipeline ID, name, path, repository, and YAML file path.", {
823
- project: z.string().describe("The project name"),
824
- }, async ({ project }) => {
825
- try {
826
- const service = getAzureDevOpsService();
827
- const result = await service.listPipelineDefinitions(project);
828
- return { content: [{ type: "text", text: `Pipeline definitions in project '${project}':\n\n${JSON.stringify(result, null, 2)}` }] };
829
- }
830
- catch (error) {
831
- console.error("Error listing pipelines:", error);
832
- return { content: [{ type: "text", text: `Failed to list pipelines: ${error.message}` }] };
833
- }
834
- });
835
- adminToolsCount++;
836
- server.tool("get-pipeline-definition", "Get detailed YAML pipeline definition including triggers, variables (secrets masked), queue settings, and repository configuration.", {
837
- project: z.string().describe("The project name"),
838
- definitionId: z.number().describe("The pipeline definition ID"),
839
- }, async ({ project, definitionId }) => {
840
- try {
841
- const service = getAzureDevOpsService();
842
- const result = await service.getPipelineDefinition(project, definitionId);
843
- return { content: [{ type: "text", text: `Pipeline definition ${definitionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
844
- }
845
- catch (error) {
846
- console.error("Error getting pipeline definition:", error);
847
- return { content: [{ type: "text", text: `Failed to get pipeline definition: ${error.message}` }] };
848
- }
849
- });
850
- adminToolsCount++;
851
- server.tool("get-pipeline-yaml", "Get the YAML content for a pipeline definition. Returns the raw azure-pipelines.yml content.", {
852
- project: z.string().describe("The project name"),
853
- definitionId: z.number().describe("The pipeline definition ID"),
854
- }, async ({ project, definitionId }) => {
855
- try {
856
- const service = getAzureDevOpsService();
857
- const result = await service.getPipelineYaml(project, definitionId);
858
- return { content: [{ type: "text", text: `Pipeline YAML for definition ${definitionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
859
- }
860
- catch (error) {
861
- console.error("Error getting pipeline YAML:", error);
862
- return { content: [{ type: "text", text: `Failed to get pipeline YAML: ${error.message}` }] };
863
- }
864
- });
865
- adminToolsCount++;
866
- server.tool("list-pipeline-runs", "List recent pipeline runs for a definition. Returns build ID, status, result, branch, timestamps, and who triggered it.", {
867
- project: z.string().describe("The project name"),
868
- definitionId: z.number().describe("The pipeline definition ID"),
869
- top: z.number().optional().describe("Maximum number of results (default: 10)"),
870
- }, async ({ project, definitionId, top }) => {
871
- try {
872
- const service = getAzureDevOpsService();
873
- const result = await service.listPipelineRuns(project, definitionId, top || 10);
874
- return { content: [{ type: "text", text: `Recent runs for pipeline ${definitionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
875
- }
876
- catch (error) {
877
- console.error("Error listing pipeline runs:", error);
878
- return { content: [{ type: "text", text: `Failed to list pipeline runs: ${error.message}` }] };
879
- }
880
- });
881
- adminToolsCount++;
882
- server.tool("get-build-status", "Get build status and details. Use detail='summary' for basic status, 'timeline' for step breakdown (default scope='problems' shows only failed/warning items), or 'full' for logs. The timelineScope controls what records are included: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything).", {
883
- project: z.string().describe("The project name"),
884
- buildId: z.number().describe("The build ID"),
885
- detail: z.enum(["summary", "timeline", "full"]).optional().describe("Level of detail: 'summary' (default), 'timeline' (include steps), or 'full' (include logs)"),
886
- timelineScope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Timeline scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
887
- maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
888
- }, async ({ project, buildId, detail, timelineScope, maxIssues }) => {
889
- try {
890
- const service = getAzureDevOpsService();
891
- const result = await service.getBuildStatus(project, buildId, detail || 'summary', timelineScope || 'problems', maxIssues || 5);
892
- return { content: [{ type: "text", text: `Build ${buildId} status:\n\n${JSON.stringify(result, null, 2)}` }] };
893
- }
894
- catch (error) {
895
- console.error("Error getting build status:", error);
896
- return { content: [{ type: "text", text: `Failed to get build status: ${error.message}` }] };
897
- }
898
- });
899
- adminToolsCount++;
900
- server.tool("get-build-timeline", "Get step-by-step breakdown of a build. Shows stages, jobs, and tasks with timing, status, and error/warning counts. Use scope to control output size: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything). Always includes summary stats regardless of scope.", {
901
- project: z.string().describe("The project name"),
902
- buildId: z.number().describe("The build ID"),
903
- scope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Filter scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
904
- maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
905
- }, async ({ project, buildId, scope, maxIssues }) => {
906
- try {
907
- const service = getAzureDevOpsService();
908
- const result = await service.getBuildTimeline(project, buildId, scope || 'problems', maxIssues || 5);
909
- return { content: [{ type: "text", text: `Build ${buildId} timeline:\n\n${JSON.stringify(result, null, 2)}` }] };
910
- }
911
- catch (error) {
912
- console.error("Error getting build timeline:", error);
913
- return { content: [{ type: "text", text: `Failed to get build timeline: ${error.message}` }] };
914
- }
915
- });
916
- adminToolsCount++;
917
- server.tool("get-build-logs", "Get build logs. Without logId, returns list of available logs with line counts. With logId, returns that log's content. NOTE: For verbose logs, consider using a sub-agent to analyze the content.", {
918
- project: z.string().describe("The project name"),
919
- buildId: z.number().describe("The build ID"),
920
- logId: z.number().optional().describe("Optional specific log ID to retrieve content"),
921
- }, async ({ project, buildId, logId }) => {
922
- try {
923
- const service = getAzureDevOpsService();
924
- const result = await service.getBuildLogs(project, buildId, logId);
925
- return { content: [{ type: "text", text: `Build ${buildId} logs:\n\n${JSON.stringify(result, null, 2)}` }] };
926
- }
927
- catch (error) {
928
- console.error("Error getting build logs:", error);
929
- return { content: [{ type: "text", text: `Failed to get build logs: ${error.message}` }] };
930
- }
931
- });
932
- adminToolsCount++;
933
- // ========================================
934
- // SERVICE CONNECTION READ-ONLY TOOLS (Tier 1)
935
- // ========================================
936
- server.tool("list-service-connections", "List all service connections in a project. Shows connection type, URL, authorization scheme, and sharing status. Credentials are masked.", {
937
- project: z.string().describe("The project name"),
938
- }, async ({ project }) => {
939
- try {
940
- const service = getAzureDevOpsService();
941
- const result = await service.listServiceConnections(project);
942
- return { content: [{ type: "text", text: `Service connections in project '${project}':\n\n${JSON.stringify(result, null, 2)}` }] };
943
- }
944
- catch (error) {
945
- console.error("Error listing service connections:", error);
946
- return { content: [{ type: "text", text: `Failed to list service connections: ${error.message}` }] };
947
- }
948
- });
949
- adminToolsCount++;
950
- server.tool("get-service-connection", "Get detailed service connection configuration. Returns type, URL, authorization scheme, data fields, and project references. Secrets are masked.", {
951
- project: z.string().describe("The project name"),
952
- connectionId: z.string().describe("The service connection ID (GUID)"),
953
- }, async ({ project, connectionId }) => {
954
- try {
955
- const service = getAzureDevOpsService();
956
- const result = await service.getServiceConnection(project, connectionId);
957
- return { content: [{ type: "text", text: `Service connection ${connectionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
958
- }
959
- catch (error) {
960
- console.error("Error getting service connection:", error);
961
- return { content: [{ type: "text", text: `Failed to get service connection: ${error.message}` }] };
962
- }
963
- });
964
- adminToolsCount++;
965
- server.tool("get-service-connection-types", "Get all available service connection types (Azure, AWS, Docker, GitHub, etc.) with their authentication schemes and configuration options.", {}, async () => {
966
- try {
967
- const service = getAzureDevOpsService();
968
- const result = await service.getServiceConnectionTypes();
969
- return { content: [{ type: "text", text: `Available service connection types:\n\n${JSON.stringify(result, null, 2)}` }] };
970
- }
971
- catch (error) {
972
- console.error("Error getting service connection types:", error);
973
- return { content: [{ type: "text", text: `Failed to get service connection types: ${error.message}` }] };
974
- }
975
- });
976
- adminToolsCount++;
977
- // ========================================
978
- // AGENT POOL READ-ONLY TOOLS (Tier 1)
979
- // ========================================
980
- server.tool("list-agent-pools", "List all agent pools in the organization. Shows pool type (automation/deployment), size, hosted status, and auto-provision settings.", {
981
- poolType: z.enum(["automation", "deployment"]).optional().describe("Optional filter: 'automation' or 'deployment'"),
982
- }, async ({ poolType }) => {
983
- try {
984
- const service = getAzureDevOpsService();
985
- const result = await service.listAgentPools(poolType);
986
- return { content: [{ type: "text", text: `Agent pools:\n\n${JSON.stringify(result, null, 2)}` }] };
987
- }
988
- catch (error) {
989
- console.error("Error listing agent pools:", error);
990
- return { content: [{ type: "text", text: `Failed to list agent pools: ${error.message}` }] };
991
- }
992
- });
993
- adminToolsCount++;
994
- server.tool("get-agent-pool", "Get detailed agent pool configuration including auto-provision, auto-update, auto-size settings, and owner information.", {
995
- poolId: z.number().describe("The agent pool ID"),
996
- }, async ({ poolId }) => {
997
- try {
998
- const service = getAzureDevOpsService();
999
- const result = await service.getAgentPool(poolId);
1000
- return { content: [{ type: "text", text: `Agent pool ${poolId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1001
- }
1002
- catch (error) {
1003
- console.error("Error getting agent pool:", error);
1004
- return { content: [{ type: "text", text: `Failed to get agent pool: ${error.message}` }] };
1005
- }
1006
- });
1007
- adminToolsCount++;
1008
- server.tool("list-agents", "List all agents in a pool. Shows agent name, version, OS, enabled status, and current status (online/offline).", {
1009
- poolId: z.number().describe("The agent pool ID"),
1010
- includeCapabilities: z.boolean().optional().describe("Include system and user capabilities (default: false)"),
1011
- }, async ({ poolId, includeCapabilities }) => {
1012
- try {
1013
- const service = getAzureDevOpsService();
1014
- const result = await service.listAgents(poolId, includeCapabilities || false);
1015
- return { content: [{ type: "text", text: `Agents in pool ${poolId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1016
- }
1017
- catch (error) {
1018
- console.error("Error listing agents:", error);
1019
- return { content: [{ type: "text", text: `Failed to list agents: ${error.message}` }] };
1020
- }
1021
- });
1022
- adminToolsCount++;
1023
- server.tool("get-agent", "Get detailed agent information including capabilities, current assignment, and last completed request.", {
1024
- poolId: z.number().describe("The agent pool ID"),
1025
- agentId: z.number().describe("The agent ID"),
1026
- }, async ({ poolId, agentId }) => {
1027
- try {
1028
- const service = getAzureDevOpsService();
1029
- const result = await service.getAgent(poolId, agentId);
1030
- return { content: [{ type: "text", text: `Agent ${agentId} in pool ${poolId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1031
- }
1032
- catch (error) {
1033
- console.error("Error getting agent:", error);
1034
- return { content: [{ type: "text", text: `Failed to get agent: ${error.message}` }] };
1035
- }
1036
- });
1037
- adminToolsCount++;
1038
- // ========================================
1039
- // ENVIRONMENT READ-ONLY TOOLS (Tier 1)
1040
- // ========================================
1041
- server.tool("list-environments", "List all deployment environments in a project. Shows environment name, description, and modification info.", {
1042
- project: z.string().describe("The project name"),
1043
- }, async ({ project }) => {
1044
- try {
1045
- const service = getAzureDevOpsService();
1046
- const result = await service.listEnvironments(project);
1047
- return { content: [{ type: "text", text: `Environments in project '${project}':\n\n${JSON.stringify(result, null, 2)}` }] };
1048
- }
1049
- catch (error) {
1050
- console.error("Error listing environments:", error);
1051
- return { content: [{ type: "text", text: `Failed to list environments: ${error.message}` }] };
1052
- }
1053
- });
1054
- adminToolsCount++;
1055
- server.tool("get-environment", "Get detailed environment configuration including associated resources (Kubernetes, VMs, etc.).", {
1056
- project: z.string().describe("The project name"),
1057
- environmentId: z.number().describe("The environment ID"),
1058
- }, async ({ project, environmentId }) => {
1059
- try {
1060
- const service = getAzureDevOpsService();
1061
- const result = await service.getEnvironment(project, environmentId);
1062
- return { content: [{ type: "text", text: `Environment ${environmentId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1063
- }
1064
- catch (error) {
1065
- console.error("Error getting environment:", error);
1066
- return { content: [{ type: "text", text: `Failed to get environment: ${error.message}` }] };
1067
- }
1068
- });
1069
- adminToolsCount++;
1070
- server.tool("get-environment-deployments", "Get deployment history for an environment. Shows pipeline, owner, start/finish times, and result.", {
1071
- project: z.string().describe("The project name"),
1072
- environmentId: z.number().describe("The environment ID"),
1073
- top: z.number().optional().describe("Maximum number of results (default: 10)"),
1074
- }, async ({ project, environmentId, top }) => {
1075
- try {
1076
- const service = getAzureDevOpsService();
1077
- const result = await service.getEnvironmentDeployments(project, environmentId, top || 10);
1078
- return { content: [{ type: "text", text: `Deployments to environment ${environmentId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1079
- }
1080
- catch (error) {
1081
- console.error("Error getting environment deployments:", error);
1082
- return { content: [{ type: "text", text: `Failed to get environment deployments: ${error.message}` }] };
1083
- }
1084
- });
1085
- adminToolsCount++;
1086
- server.tool("get-environment-checks", "Get all checks (approvals, business hours, branch control, etc.) configured for an environment. Essential for understanding pipeline approval requirements.", {
1087
- project: z.string().describe("The project name"),
1088
- environmentId: z.number().describe("The environment ID"),
1089
- }, async ({ project, environmentId }) => {
1090
- try {
1091
- const service = getAzureDevOpsService();
1092
- const result = await service.getEnvironmentChecks(project, environmentId);
1093
- return { content: [{ type: "text", text: `Checks for environment ${environmentId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1094
- }
1095
- catch (error) {
1096
- console.error("Error getting environment checks:", error);
1097
- return { content: [{ type: "text", text: `Failed to get environment checks: ${error.message}` }] };
1098
- }
1099
- });
1100
- adminToolsCount++;
1101
- }
1102
- // ========================================
1103
- // PIPELINE UPSERT TOOLS (Tier 2)
894
+ // PULL REQUEST WRITE TOOLS (Conditional)
1104
895
  // ========================================
1105
- if (enablePipelineUpsert) {
1106
- server.tool("admin-create-pipeline", "Create a new YAML pipeline definition. Requires repository ID and path to YAML file. (requires AZUREDEVOPS_ENABLE_PIPELINE_UPSERT=true)", {
1107
- project: z.string().describe("The project name"),
1108
- name: z.string().describe("Pipeline name"),
1109
- repositoryId: z.string().describe("Repository ID (GUID from get-repos or Azure DevOps UI)"),
1110
- yamlPath: z.string().describe("Path to YAML file in repository (e.g., 'azure-pipelines.yml' or 'pipelines/build.yml')"),
1111
- folder: z.string().optional().describe("Optional folder path (default: root)"),
1112
- }, async ({ project, name, repositoryId, yamlPath, folder }) => {
1113
- try {
1114
- const service = getAzureDevOpsService();
1115
- const result = await service.createPipelineDefinition(project, name, repositoryId, yamlPath, folder);
1116
- return { content: [{ type: "text", text: `Created pipeline '${name}':\n\n${JSON.stringify(result, null, 2)}` }] };
1117
- }
1118
- catch (error) {
1119
- console.error("Error creating pipeline:", error);
1120
- return { content: [{ type: "text", text: `Failed to create pipeline: ${error.message}` }] };
1121
- }
1122
- });
1123
- adminToolsCount++;
1124
- server.tool("admin-update-pipeline", "Update a pipeline definition (name, path, queue status, triggers, or variables). (requires AZUREDEVOPS_ENABLE_PIPELINE_UPSERT=true)", {
1125
- project: z.string().describe("The project name"),
1126
- definitionId: z.number().describe("The pipeline definition ID"),
1127
- name: z.string().optional().describe("New pipeline name"),
1128
- path: z.string().optional().describe("New folder path"),
1129
- queueStatus: z.enum(["enabled", "disabled", "paused"]).optional().describe("Queue status"),
1130
- variables: z.record(z.object({
1131
- value: z.string(),
1132
- isSecret: z.boolean().optional(),
1133
- allowOverride: z.boolean().optional()
1134
- })).optional().describe("Pipeline variables to set"),
1135
- }, async ({ project, definitionId, name, path, queueStatus, variables }) => {
1136
- try {
1137
- const service = getAzureDevOpsService();
1138
- const updates = {};
1139
- if (name)
1140
- updates.name = name;
1141
- if (path)
1142
- updates.path = path;
1143
- if (queueStatus)
1144
- updates.queueStatus = queueStatus;
1145
- if (variables)
1146
- updates.variables = variables;
1147
- const result = await service.updatePipelineDefinition(project, definitionId, updates);
1148
- return { content: [{ type: "text", text: `Updated pipeline ${definitionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1149
- }
1150
- catch (error) {
1151
- console.error("Error updating pipeline:", error);
1152
- return { content: [{ type: "text", text: `Failed to update pipeline: ${error.message}` }] };
1153
- }
1154
- });
1155
- adminToolsCount++;
1156
- server.tool("admin-rename-pipeline", "Rename a pipeline definition. (requires AZUREDEVOPS_ENABLE_PIPELINE_UPSERT=true)", {
1157
- project: z.string().describe("The project name"),
1158
- definitionId: z.number().describe("The pipeline definition ID"),
1159
- newName: z.string().describe("The new pipeline name"),
1160
- }, async ({ project, definitionId, newName }) => {
1161
- try {
1162
- const service = getAzureDevOpsService();
1163
- const result = await service.renamePipelineDefinition(project, definitionId, newName);
1164
- return { content: [{ type: "text", text: `Renamed pipeline ${definitionId} to '${newName}':\n\n${JSON.stringify(result, null, 2)}` }] };
1165
- }
1166
- catch (error) {
1167
- console.error("Error renaming pipeline:", error);
1168
- return { content: [{ type: "text", text: `Failed to rename pipeline: ${error.message}` }] };
1169
- }
1170
- });
1171
- adminToolsCount++;
1172
- server.tool("admin-queue-build", "Queue a new pipeline build/run. Optionally specify branch, variables, or template parameters. (requires AZUREDEVOPS_ENABLE_PIPELINE_UPSERT=true)", {
896
+ const enablePullRequestWrite = process.env.AZUREDEVOPS_ENABLE_PR_WRITE === "true";
897
+ if (enablePullRequestWrite) {
898
+ server.tool("add-pull-request-thread", "Add a comment or code review feedback to a pull request. Supports both general comments and inline comments on specific files/lines. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1173
899
  project: z.string().describe("The project name"),
1174
- definitionId: z.number().describe("The pipeline definition ID"),
1175
- branch: z.string().optional().describe("Source branch (e.g., 'refs/heads/main')"),
1176
- variables: z.record(z.string()).optional().describe("Runtime variables to pass"),
1177
- parameters: z.record(z.any()).optional().describe("Template parameters for YAML pipelines"),
1178
- }, async ({ project, definitionId, branch, variables, parameters }) => {
900
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
901
+ pullRequestId: z.number().describe("The pull request ID"),
902
+ content: z.string().describe("Comment content (markdown supported)"),
903
+ filePath: z.string().optional().describe("File path for inline comment (e.g., '/src/file.ts')"),
904
+ lineNumber: z.number().optional().describe("Line number for inline comment (right side of diff)"),
905
+ status: z.enum(["active", "fixed", "wontFix", "closed", "byDesign", "pending"]).optional()
906
+ .describe("Thread status (default: active)"),
907
+ }, async ({ project, repositoryId, pullRequestId, content, filePath, lineNumber, status }) => {
1179
908
  try {
1180
909
  const service = getAzureDevOpsService();
1181
- const result = await service.queueBuild(project, definitionId, branch, variables, parameters);
1182
- return { content: [{ type: "text", text: `Queued build for pipeline ${definitionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
910
+ const result = await service.addPullRequestThread(project, repositoryId, pullRequestId, content, filePath, lineNumber, status || 'active');
911
+ return { content: [{ type: "text", text: `Added comment to PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1183
912
  }
1184
913
  catch (error) {
1185
- console.error("Error queuing build:", error);
1186
- return { content: [{ type: "text", text: `Failed to queue build: ${error.message}` }] };
914
+ console.error("Error adding pull request thread:", error);
915
+ return { content: [{ type: "text", text: `Failed to add pull request thread: ${error.message}` }] };
1187
916
  }
1188
917
  });
1189
- adminToolsCount++;
1190
- server.tool("admin-cancel-build", "Cancel a running build. The build will finish its current task before stopping. (requires AZUREDEVOPS_ENABLE_PIPELINE_UPSERT=true)", {
918
+ server.tool("create-pull-request", "Create a new pull request in a Git repository. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1191
919
  project: z.string().describe("The project name"),
1192
- buildId: z.number().describe("The build ID to cancel"),
1193
- }, async ({ project, buildId }) => {
920
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
921
+ sourceRefName: z.string().describe(descWithExamples("Source branch full ref name", PR_BRANCH_REF_EXAMPLES)),
922
+ targetRefName: z.string().describe(descWithExamples("Target branch full ref name", PR_BRANCH_REF_EXAMPLES)),
923
+ title: z.string().describe("Pull request title"),
924
+ description: z.string().optional().describe("Pull request description (markdown supported)"),
925
+ reviewerIds: z.array(z.string()).optional().describe("Reviewer GUIDs or unique names"),
926
+ isDraft: z.boolean().optional().describe("Create as draft PR (default: false)"),
927
+ }, async ({ project, repositoryId, sourceRefName, targetRefName, title, description, reviewerIds, isDraft }) => {
1194
928
  try {
1195
929
  const service = getAzureDevOpsService();
1196
- const result = await service.cancelBuild(project, buildId);
1197
- return { content: [{ type: "text", text: `Cancelled build ${buildId}:\n\n${JSON.stringify(result, null, 2)}` }] };
930
+ const result = await service.createPullRequest(project, repositoryId, sourceRefName, targetRefName, title, description, reviewerIds, isDraft);
931
+ return { content: [{ type: "text", text: `Created PR #${result.pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1198
932
  }
1199
933
  catch (error) {
1200
- console.error("Error cancelling build:", error);
1201
- return { content: [{ type: "text", text: `Failed to cancel build: ${error.message}` }] };
934
+ console.error("Error creating pull request:", error);
935
+ return { content: [{ type: "text", text: `Failed to create pull request: ${error.message}` }] };
1202
936
  }
1203
937
  });
1204
- adminToolsCount++;
1205
- server.tool("admin-retry-build", "Retry a failed build with the same configuration. Creates a new build with same definition, branch, and parameters. (requires AZUREDEVOPS_ENABLE_PIPELINE_UPSERT=true)", {
938
+ server.tool("update-pull-request", "Update a pull request's title, description, status, or draft state. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1206
939
  project: z.string().describe("The project name"),
1207
- buildId: z.number().describe("The failed build ID to retry"),
1208
- }, async ({ project, buildId }) => {
940
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
941
+ pullRequestId: z.number().describe("The pull request ID"),
942
+ title: z.string().optional().describe("New title"),
943
+ description: z.string().optional().describe("New description"),
944
+ status: z.enum(["abandoned", "active"]).optional().describe("Set PR status (abandoned or active)"),
945
+ isDraft: z.boolean().optional().describe("Set draft state"),
946
+ }, async ({ project, repositoryId, pullRequestId, title, description, status, isDraft }) => {
1209
947
  try {
1210
948
  const service = getAzureDevOpsService();
1211
- const result = await service.retryBuild(project, buildId);
1212
- return { content: [{ type: "text", text: `Retried build ${buildId}, new build:\n\n${JSON.stringify(result, null, 2)}` }] };
949
+ const result = await service.updatePullRequest(project, repositoryId, pullRequestId, { title, description, status, isDraft });
950
+ return { content: [{ type: "text", text: `Updated PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1213
951
  }
1214
952
  catch (error) {
1215
- console.error("Error retrying build:", error);
1216
- return { content: [{ type: "text", text: `Failed to retry build: ${error.message}` }] };
953
+ console.error("Error updating pull request:", error);
954
+ return { content: [{ type: "text", text: `Failed to update pull request: ${error.message}` }] };
1217
955
  }
1218
956
  });
1219
- adminToolsCount++;
1220
- }
1221
- // ========================================
1222
- // PIPELINE DELETE TOOLS (Tier 3)
1223
- // ========================================
1224
- if (enablePipelineDelete) {
1225
- server.tool("admin-delete-pipeline", "⚠️ DESTRUCTIVE: Delete a pipeline definition. This cannot be undone. (requires AZUREDEVOPS_ENABLE_PIPELINE_DELETE=true)", {
957
+ server.tool("complete-pull-request", "Complete (merge) a pull request with configurable merge strategy. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1226
958
  project: z.string().describe("The project name"),
1227
- definitionId: z.number().describe("The pipeline definition ID to delete"),
1228
- }, async ({ project, definitionId }) => {
959
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
960
+ pullRequestId: z.number().describe("The pull request ID"),
961
+ mergeStrategy: z.enum(["squash", "noFastForward", "rebase", "rebaseMerge"]).optional()
962
+ .describe(descWithExamples("Merge strategy (default: squash)", PR_MERGE_STRATEGY_EXAMPLES)),
963
+ deleteSourceBranch: z.boolean().optional().describe("Delete source branch after merge (default: true)"),
964
+ transitionWorkItems: z.boolean().optional().describe("Transition linked work items (default: true)"),
965
+ mergeCommitMessage: z.string().optional().describe("Custom merge commit message"),
966
+ }, async ({ project, repositoryId, pullRequestId, mergeStrategy, deleteSourceBranch, transitionWorkItems, mergeCommitMessage }) => {
1229
967
  try {
1230
968
  const service = getAzureDevOpsService();
1231
- const result = await service.deletePipelineDefinition(project, definitionId);
1232
- return { content: [{ type: "text", text: `Deleted pipeline ${definitionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
969
+ const result = await service.completePullRequest(project, repositoryId, pullRequestId, mergeStrategy || 'squash', deleteSourceBranch !== undefined ? deleteSourceBranch : true, transitionWorkItems !== undefined ? transitionWorkItems : true, mergeCommitMessage);
970
+ return { content: [{ type: "text", text: `Completed PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1233
971
  }
1234
972
  catch (error) {
1235
- console.error("Error deleting pipeline:", error);
1236
- return { content: [{ type: "text", text: `Failed to delete pipeline: ${error.message}` }] };
973
+ console.error("Error completing pull request:", error);
974
+ return { content: [{ type: "text", text: `Failed to complete pull request: ${error.message}` }] };
1237
975
  }
1238
976
  });
1239
- adminToolsCount++;
1240
- }
1241
- // ========================================
1242
- // SERVICE CONNECTION UPSERT TOOLS (Tier 2)
1243
- // ========================================
1244
- if (enableServiceConnUpsert) {
1245
- server.tool("admin-create-service-connection", "Create a new service connection. Use get-service-connection-types to see available types and auth schemes. (requires AZUREDEVOPS_ENABLE_SERVICE_CONN_UPSERT=true)", {
977
+ server.tool("add-pr-reviewer", "Add or remove a reviewer from a pull request. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1246
978
  project: z.string().describe("The project name"),
1247
- name: z.string().describe("Connection name"),
1248
- type: z.string().describe("Connection type (e.g., 'azurerm', 'github', 'docker', 'aws')"),
1249
- url: z.string().optional().describe("Service URL (required for some types)"),
1250
- description: z.string().optional().describe("Connection description"),
1251
- authorization: z.object({
1252
- scheme: z.string().describe("Auth scheme (e.g., 'ServicePrincipal', 'PersonalAccessToken')"),
1253
- parameters: z.record(z.string()).optional().describe("Auth parameters (credentials)")
1254
- }).optional().describe("Authorization configuration"),
1255
- data: z.record(z.string()).optional().describe("Type-specific configuration data"),
1256
- }, async ({ project, name, type, url, description, authorization, data }) => {
979
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
980
+ pullRequestId: z.number().describe("The pull request ID"),
981
+ reviewerId: z.string().describe("Reviewer GUID or unique name"),
982
+ isRequired: z.boolean().optional().describe("Whether the reviewer is required (default: false)"),
983
+ remove: z.boolean().optional().describe("Set to true to remove the reviewer instead of adding"),
984
+ }, async ({ project, repositoryId, pullRequestId, reviewerId, isRequired, remove }) => {
1257
985
  try {
1258
986
  const service = getAzureDevOpsService();
1259
- const result = await service.createServiceConnection(project, name, type, { url, description, authorization, data });
1260
- return { content: [{ type: "text", text: `Created service connection '${name}':\n\n${JSON.stringify(result, null, 2)}` }] };
987
+ const result = await service.addOrRemovePrReviewer(project, repositoryId, pullRequestId, reviewerId, isRequired, remove);
988
+ return { content: [{ type: "text", text: `${remove ? 'Removed' : 'Added'} reviewer on PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1261
989
  }
1262
990
  catch (error) {
1263
- console.error("Error creating service connection:", error);
1264
- return { content: [{ type: "text", text: `Failed to create service connection: ${error.message}` }] };
991
+ console.error("Error managing PR reviewer:", error);
992
+ return { content: [{ type: "text", text: `Failed to manage PR reviewer: ${error.message}` }] };
1265
993
  }
1266
994
  });
1267
- adminToolsCount++;
1268
- server.tool("admin-update-service-connection", "Update a service connection's metadata (name, description, URL, data). Cannot update credentials for security. (requires AZUREDEVOPS_ENABLE_SERVICE_CONN_UPSERT=true)", {
995
+ server.tool("vote-pull-request", "Submit a vote (approve, reject, etc.) on a pull request. Defaults to authenticated user. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1269
996
  project: z.string().describe("The project name"),
1270
- connectionId: z.string().describe("The service connection ID (GUID)"),
1271
- name: z.string().optional().describe("New connection name"),
1272
- description: z.string().optional().describe("New description"),
1273
- url: z.string().optional().describe("New service URL"),
1274
- data: z.record(z.string()).optional().describe("Updated data fields"),
1275
- }, async ({ project, connectionId, name, description, url, data }) => {
1276
- try {
1277
- const service = getAzureDevOpsService();
1278
- const updates = {};
1279
- if (name)
1280
- updates.name = name;
1281
- if (description)
1282
- updates.description = description;
1283
- if (url)
1284
- updates.url = url;
1285
- if (data)
1286
- updates.data = data;
1287
- const result = await service.updateServiceConnection(project, connectionId, updates);
1288
- return { content: [{ type: "text", text: `Updated service connection ${connectionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1289
- }
1290
- catch (error) {
1291
- console.error("Error updating service connection:", error);
1292
- return { content: [{ type: "text", text: `Failed to update service connection: ${error.message}` }] };
1293
- }
1294
- });
1295
- adminToolsCount++;
1296
- server.tool("admin-share-service-connection", "Share a service connection with other projects. (requires AZUREDEVOPS_ENABLE_SERVICE_CONN_UPSERT=true)", {
1297
- connectionId: z.string().describe("The service connection ID (GUID)"),
1298
- projectIds: z.array(z.string()).describe("Array of project IDs to share with"),
1299
- }, async ({ connectionId, projectIds }) => {
997
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
998
+ pullRequestId: z.number().describe("The pull request ID"),
999
+ vote: z.enum(["approve", "approveWithSuggestions", "noResponse", "waitForAuthor", "reject"])
1000
+ .describe(descWithExamples("Vote to submit", PR_VOTE_EXAMPLES)),
1001
+ reviewerId: z.string().optional().describe("Reviewer GUID (defaults to authenticated user)"),
1002
+ }, async ({ project, repositoryId, pullRequestId, vote, reviewerId }) => {
1300
1003
  try {
1301
1004
  const service = getAzureDevOpsService();
1302
- const result = await service.shareServiceConnection(connectionId, projectIds);
1303
- return { content: [{ type: "text", text: `Shared service connection ${connectionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1005
+ const result = await service.votePullRequest(project, repositoryId, pullRequestId, vote, reviewerId);
1006
+ return { content: [{ type: "text", text: `Voted '${vote}' on PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1304
1007
  }
1305
1008
  catch (error) {
1306
- console.error("Error sharing service connection:", error);
1307
- return { content: [{ type: "text", text: `Failed to share service connection: ${error.message}` }] };
1009
+ console.error("Error voting on pull request:", error);
1010
+ return { content: [{ type: "text", text: `Failed to vote on pull request: ${error.message}` }] };
1308
1011
  }
1309
1012
  });
1310
- adminToolsCount++;
1311
- }
1312
- // ========================================
1313
- // SERVICE CONNECTION DELETE TOOLS (Tier 3)
1314
- // ========================================
1315
- if (enableServiceConnDelete) {
1316
- server.tool("admin-delete-service-connection", "⚠️ DESTRUCTIVE: Delete a service connection. Pipelines using this connection will fail. (requires AZUREDEVOPS_ENABLE_SERVICE_CONN_DELETE=true)", {
1013
+ server.tool("reply-to-pr-thread", "Reply to a pull request comment thread and/or update thread status (e.g., resolve). (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
1317
1014
  project: z.string().describe("The project name"),
1318
- connectionId: z.string().describe("The service connection ID (GUID) to delete"),
1319
- }, async ({ project, connectionId }) => {
1015
+ repositoryId: z.string().describe("Repository ID (GUID) or name"),
1016
+ pullRequestId: z.number().describe("The pull request ID"),
1017
+ threadId: z.number().describe("The thread ID to reply to"),
1018
+ content: z.string().optional().describe("Reply text (markdown supported)"),
1019
+ status: z.enum(["active", "fixed", "wontFix", "closed", "byDesign", "pending"]).optional()
1020
+ .describe("Update thread status (e.g., 'fixed' to resolve)"),
1021
+ }, async ({ project, repositoryId, pullRequestId, threadId, content, status }) => {
1320
1022
  try {
1321
1023
  const service = getAzureDevOpsService();
1322
- const result = await service.deleteServiceConnection(project, connectionId);
1323
- return { content: [{ type: "text", text: `Deleted service connection ${connectionId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1024
+ const result = await service.replyToPrThread(project, repositoryId, pullRequestId, threadId, content, status);
1025
+ return { content: [{ type: "text", text: `Reply to thread #${threadId} on PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1324
1026
  }
1325
1027
  catch (error) {
1326
- console.error("Error deleting service connection:", error);
1327
- return { content: [{ type: "text", text: `Failed to delete service connection: ${error.message}` }] };
1028
+ console.error("Error replying to PR thread:", error);
1029
+ return { content: [{ type: "text", text: `Failed to reply to PR thread: ${error.message}` }] };
1328
1030
  }
1329
1031
  });
1330
- adminToolsCount++;
1331
1032
  }
1332
1033
  // ========================================
1333
- // VARIABLE GROUP UPSERT TOOLS (Tier 2)
1034
+ // VARIABLE GROUP TOOLS
1334
1035
  // ========================================
1335
- if (enableVariableGroupUpsert) {
1336
- server.tool("admin-create-variable-group", "Create a new variable group. Variables can be marked as secret. (requires AZUREDEVOPS_ENABLE_VARIABLE_GROUP_UPSERT=true)", {
1337
- project: z.string().describe("The project name"),
1338
- name: z.string().describe("Variable group name"),
1339
- description: z.string().optional().describe("Variable group description"),
1340
- variables: z.record(z.object({
1341
- value: z.string(),
1342
- isSecret: z.boolean().optional()
1343
- })).optional().describe("Initial variables to set"),
1344
- }, async ({ project, name, description, variables }) => {
1345
- try {
1346
- const service = getAzureDevOpsService();
1347
- const result = await service.createVariableGroup(project, name, description, variables);
1348
- return { content: [{ type: "text", text: `Created variable group '${name}':\n\n${JSON.stringify(result, null, 2)}` }] };
1349
- }
1350
- catch (error) {
1351
- console.error("Error creating variable group:", error);
1352
- return { content: [{ type: "text", text: `Failed to create variable group: ${error.message}` }] };
1353
- }
1354
- });
1355
- adminToolsCount++;
1356
- server.tool("admin-update-variable-group", "Update a variable group's name or description. Use admin-set-variable to modify variables. (requires AZUREDEVOPS_ENABLE_VARIABLE_GROUP_UPSERT=true)", {
1357
- project: z.string().describe("The project name"),
1358
- groupId: z.number().describe("The variable group ID"),
1359
- name: z.string().optional().describe("New name"),
1360
- description: z.string().optional().describe("New description"),
1361
- }, async ({ project, groupId, name, description }) => {
1362
- try {
1363
- const service = getAzureDevOpsService();
1364
- const updates = {};
1365
- if (name)
1366
- updates.name = name;
1367
- if (description)
1368
- updates.description = description;
1369
- const result = await service.updateVariableGroupMetadata(project, groupId, updates);
1370
- return { content: [{ type: "text", text: `Updated variable group ${groupId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1371
- }
1372
- catch (error) {
1373
- console.error("Error updating variable group:", error);
1374
- return { content: [{ type: "text", text: `Failed to update variable group: ${error.message}` }] };
1375
- }
1376
- });
1377
- adminToolsCount++;
1378
- server.tool("admin-set-variable", "Set or update a variable in a variable group. Creates the variable if it doesn't exist. (requires AZUREDEVOPS_ENABLE_VARIABLE_GROUP_UPSERT=true)", {
1379
- project: z.string().describe("The project name"),
1380
- groupId: z.number().describe("The variable group ID"),
1381
- variableName: z.string().describe("Variable name"),
1382
- value: z.string().describe("Variable value"),
1383
- isSecret: z.boolean().optional().describe("Mark as secret (default: false)"),
1384
- }, async ({ project, groupId, variableName, value, isSecret }) => {
1385
- try {
1386
- const service = getAzureDevOpsService();
1387
- const result = await service.setVariable(project, groupId, variableName, value, isSecret || false);
1388
- return { content: [{ type: "text", text: `Set variable '${variableName}' in group ${groupId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1389
- }
1390
- catch (error) {
1391
- console.error("Error setting variable:", error);
1392
- return { content: [{ type: "text", text: `Failed to set variable: ${error.message}` }] };
1393
- }
1394
- });
1395
- adminToolsCount++;
1396
- }
1397
- // ========================================
1398
- // VARIABLE GROUP DELETE TOOLS (Tier 3)
1399
- // ========================================
1400
- if (enableVariableGroupDelete) {
1401
- server.tool("admin-remove-variable", "Remove a variable from a variable group. (requires AZUREDEVOPS_ENABLE_VARIABLE_GROUP_DELETE=true)", {
1402
- project: z.string().describe("The project name"),
1403
- groupId: z.number().describe("The variable group ID"),
1404
- variableName: z.string().describe("Variable name to remove"),
1405
- }, async ({ project, groupId, variableName }) => {
1406
- try {
1407
- const service = getAzureDevOpsService();
1408
- const result = await service.removeVariable(project, groupId, variableName);
1409
- return { content: [{ type: "text", text: `Removed variable '${variableName}' from group ${groupId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1410
- }
1411
- catch (error) {
1412
- console.error("Error removing variable:", error);
1413
- return { content: [{ type: "text", text: `Failed to remove variable: ${error.message}` }] };
1414
- }
1415
- });
1416
- adminToolsCount++;
1417
- server.tool("admin-delete-variable-group", "⚠️ DESTRUCTIVE: Delete a variable group. Pipelines using this group will fail. (requires AZUREDEVOPS_ENABLE_VARIABLE_GROUP_DELETE=true)", {
1418
- project: z.string().describe("The project name"),
1419
- groupId: z.number().describe("The variable group ID to delete"),
1420
- }, async ({ project, groupId }) => {
1421
- try {
1422
- const service = getAzureDevOpsService();
1423
- const result = await service.deleteVariableGroup(project, groupId);
1424
- return { content: [{ type: "text", text: `Deleted variable group ${groupId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1425
- }
1426
- catch (error) {
1427
- console.error("Error deleting variable group:", error);
1428
- return { content: [{ type: "text", text: `Failed to delete variable group: ${error.message}` }] };
1429
- }
1430
- });
1431
- adminToolsCount++;
1432
- }
1433
- // ========================================
1434
- // AGENT POOL UPSERT TOOLS (Tier 2)
1435
- // ========================================
1436
- if (enableAgentPoolUpsert) {
1437
- server.tool("admin-update-agent-pool", "Update agent pool settings (auto-provision, auto-update, auto-size, target size). (requires AZUREDEVOPS_ENABLE_AGENT_POOL_UPSERT=true)", {
1438
- poolId: z.number().describe("The agent pool ID"),
1439
- autoProvision: z.boolean().optional().describe("Auto-provision pool to new projects"),
1440
- autoUpdate: z.boolean().optional().describe("Auto-update agents"),
1441
- autoSize: z.boolean().optional().describe("Auto-size pool based on demand"),
1442
- targetSize: z.number().optional().describe("Target pool size for auto-scaling"),
1443
- }, async ({ poolId, autoProvision, autoUpdate, autoSize, targetSize }) => {
1444
- try {
1445
- const service = getAzureDevOpsService();
1446
- const updates = {};
1447
- if (autoProvision !== undefined)
1448
- updates.autoProvision = autoProvision;
1449
- if (autoUpdate !== undefined)
1450
- updates.autoUpdate = autoUpdate;
1451
- if (autoSize !== undefined)
1452
- updates.autoSize = autoSize;
1453
- if (targetSize !== undefined)
1454
- updates.targetSize = targetSize;
1455
- const result = await service.updateAgentPool(poolId, updates);
1456
- return { content: [{ type: "text", text: `Updated agent pool ${poolId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1457
- }
1458
- catch (error) {
1459
- console.error("Error updating agent pool:", error);
1460
- return { content: [{ type: "text", text: `Failed to update agent pool: ${error.message}` }] };
1461
- }
1462
- });
1463
- adminToolsCount++;
1464
- server.tool("admin-enable-agent", "Enable a disabled agent to accept new jobs. (requires AZUREDEVOPS_ENABLE_AGENT_POOL_UPSERT=true)", {
1465
- poolId: z.number().describe("The agent pool ID"),
1466
- agentId: z.number().describe("The agent ID"),
1467
- }, async ({ poolId, agentId }) => {
1468
- try {
1469
- const service = getAzureDevOpsService();
1470
- const result = await service.enableAgent(poolId, agentId);
1471
- return { content: [{ type: "text", text: `Enabled agent ${agentId} in pool ${poolId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1472
- }
1473
- catch (error) {
1474
- console.error("Error enabling agent:", error);
1475
- return { content: [{ type: "text", text: `Failed to enable agent: ${error.message}` }] };
1476
- }
1477
- });
1478
- adminToolsCount++;
1479
- }
1036
+ server.tool("list-variable-groups", "List all variable groups in an Azure DevOps project. Variable groups store values and secrets used in pipelines.", {
1037
+ project: z.string().describe("The project name"),
1038
+ }, async ({ project }) => {
1039
+ try {
1040
+ const service = getAzureDevOpsService();
1041
+ const result = await service.getVariableGroups(project);
1042
+ const resultStr = JSON.stringify(result, null, 2);
1043
+ return {
1044
+ content: [
1045
+ {
1046
+ type: "text",
1047
+ text: `Variable groups in project '${project}':\n\n${resultStr}`,
1048
+ },
1049
+ ],
1050
+ };
1051
+ }
1052
+ catch (error) {
1053
+ console.error("Error listing variable groups:", error);
1054
+ return {
1055
+ content: [
1056
+ {
1057
+ type: "text",
1058
+ text: `Failed to list variable groups: ${error.message}`,
1059
+ },
1060
+ ],
1061
+ };
1062
+ }
1063
+ });
1064
+ server.tool("get-variable-group", "Get a specific variable group by ID from Azure DevOps. Returns all variables (secrets are masked).", {
1065
+ project: z.string().describe("The project name"),
1066
+ groupId: z.number().describe("The variable group ID"),
1067
+ }, async ({ project, groupId }) => {
1068
+ try {
1069
+ const service = getAzureDevOpsService();
1070
+ const result = await service.getVariableGroup(project, groupId);
1071
+ const resultStr = JSON.stringify(result, null, 2);
1072
+ return {
1073
+ content: [
1074
+ {
1075
+ type: "text",
1076
+ text: `Variable group ${groupId} in project '${project}':\n\n${resultStr}`,
1077
+ },
1078
+ ],
1079
+ };
1080
+ }
1081
+ catch (error) {
1082
+ console.error("Error getting variable group:", error);
1083
+ return {
1084
+ content: [
1085
+ {
1086
+ type: "text",
1087
+ text: `Failed to get variable group: ${error.message}`,
1088
+ },
1089
+ ],
1090
+ };
1091
+ }
1092
+ });
1480
1093
  // ========================================
1481
- // AGENT POOL DISABLE TOOLS (Tier 3)
1094
+ // BUILD TROUBLESHOOTING TOOLS (Read-only)
1095
+ // NOTE: These tools are duplicated in azure-devops-admin package.
1096
+ // If you update these, also update packages/azure-devops-admin/src/index.ts
1482
1097
  // ========================================
1483
- if (enableAgentPoolDisable) {
1484
- server.tool("admin-disable-agent", "⚠️ Disable an agent. It will complete current job then stop accepting new jobs. (requires AZUREDEVOPS_ENABLE_AGENT_POOL_DISABLE=true)", {
1485
- poolId: z.number().describe("The agent pool ID"),
1486
- agentId: z.number().describe("The agent ID to disable"),
1487
- }, async ({ poolId, agentId }) => {
1488
- try {
1489
- const service = getAzureDevOpsService();
1490
- const result = await service.disableAgent(poolId, agentId);
1491
- return { content: [{ type: "text", text: `Disabled agent ${agentId} in pool ${poolId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1492
- }
1493
- catch (error) {
1494
- console.error("Error disabling agent:", error);
1495
- return { content: [{ type: "text", text: `Failed to disable agent: ${error.message}` }] };
1496
- }
1497
- });
1498
- adminToolsCount++;
1499
- }
1098
+ server.tool("get-build-status", "Get build status and details. Use detail='summary' for basic status, 'timeline' for step breakdown (default scope='problems' shows only failed/warning items), or 'full' for logs. The timelineScope controls what records are included: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything).", {
1099
+ project: z.string().describe("The project name"),
1100
+ buildId: z.number().describe("The build ID"),
1101
+ detail: z.enum(["summary", "timeline", "full"]).optional().describe("Level of detail: 'summary' (default), 'timeline' (include steps), or 'full' (include logs)"),
1102
+ timelineScope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Timeline scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
1103
+ maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
1104
+ }, async ({ project, buildId, detail, timelineScope, maxIssues }) => {
1105
+ try {
1106
+ const service = getAzureDevOpsService();
1107
+ const result = await service.getBuildStatus(project, buildId, detail || 'summary', timelineScope || 'problems', maxIssues || 5);
1108
+ return { content: [{ type: "text", text: `Build ${buildId} status:\n\n${JSON.stringify(result, null, 2)}` }] };
1109
+ }
1110
+ catch (error) {
1111
+ console.error("Error getting build status:", error);
1112
+ return { content: [{ type: "text", text: `Failed to get build status: ${error.message}` }] };
1113
+ }
1114
+ });
1115
+ server.tool("get-build-timeline", "Get step-by-step breakdown of a build. Shows stages, jobs, and tasks with timing, status, and error/warning counts. Use scope to control output size: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything). Always includes summary stats regardless of scope.", {
1116
+ project: z.string().describe("The project name"),
1117
+ buildId: z.number().describe("The build ID"),
1118
+ scope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Filter scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
1119
+ maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
1120
+ }, async ({ project, buildId, scope, maxIssues }) => {
1121
+ try {
1122
+ const service = getAzureDevOpsService();
1123
+ const result = await service.getBuildTimeline(project, buildId, scope || 'problems', maxIssues || 5);
1124
+ return { content: [{ type: "text", text: `Build ${buildId} timeline:\n\n${JSON.stringify(result, null, 2)}` }] };
1125
+ }
1126
+ catch (error) {
1127
+ console.error("Error getting build timeline:", error);
1128
+ return { content: [{ type: "text", text: `Failed to get build timeline: ${error.message}` }] };
1129
+ }
1130
+ });
1131
+ server.tool("get-build-logs", "Get build logs. Without logId, returns list of available logs with line counts. With logId, returns that log's content. NOTE: For verbose logs, consider using a sub-agent to analyze the content.", {
1132
+ project: z.string().describe("The project name"),
1133
+ buildId: z.number().describe("The build ID"),
1134
+ logId: z.number().optional().describe("Optional specific log ID to retrieve content"),
1135
+ }, async ({ project, buildId, logId }) => {
1136
+ try {
1137
+ const service = getAzureDevOpsService();
1138
+ const result = await service.getBuildLogs(project, buildId, logId);
1139
+ return { content: [{ type: "text", text: `Build ${buildId} logs:\n\n${JSON.stringify(result, null, 2)}` }] };
1140
+ }
1141
+ catch (error) {
1142
+ console.error("Error getting build logs:", error);
1143
+ return { content: [{ type: "text", text: `Failed to get build logs: ${error.message}` }] };
1144
+ }
1145
+ });
1500
1146
  // ========================================
1501
- // ENVIRONMENT UPSERT TOOLS (Tier 2)
1147
+ // WORK ITEM SYNC TOOLS (Local Markdown)
1502
1148
  // ========================================
1503
- if (enableEnvironmentUpsert) {
1504
- server.tool("admin-create-environment", "Create a new deployment environment. (requires AZUREDEVOPS_ENABLE_ENVIRONMENT_UPSERT=true)", {
1505
- project: z.string().describe("The project name"),
1506
- name: z.string().describe("Environment name"),
1507
- description: z.string().optional().describe("Environment description"),
1508
- }, async ({ project, name, description }) => {
1509
- try {
1510
- const service = getAzureDevOpsService();
1511
- const result = await service.createEnvironment(project, name, description);
1512
- return { content: [{ type: "text", text: `Created environment '${name}':\n\n${JSON.stringify(result, null, 2)}` }] };
1513
- }
1514
- catch (error) {
1515
- console.error("Error creating environment:", error);
1516
- return { content: [{ type: "text", text: `Failed to create environment: ${error.message}` }] };
1517
- }
1518
- });
1519
- adminToolsCount++;
1520
- server.tool("admin-update-environment", "Update an environment's name or description. (requires AZUREDEVOPS_ENABLE_ENVIRONMENT_UPSERT=true)", {
1521
- project: z.string().describe("The project name"),
1522
- environmentId: z.number().describe("The environment ID"),
1523
- name: z.string().optional().describe("New name"),
1524
- description: z.string().optional().describe("New description"),
1525
- }, async ({ project, environmentId, name, description }) => {
1526
- try {
1527
- const service = getAzureDevOpsService();
1528
- const updates = {};
1529
- if (name)
1530
- updates.name = name;
1531
- if (description)
1532
- updates.description = description;
1533
- const result = await service.updateEnvironment(project, environmentId, updates);
1534
- return { content: [{ type: "text", text: `Updated environment ${environmentId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1535
- }
1536
- catch (error) {
1537
- console.error("Error updating environment:", error);
1538
- return { content: [{ type: "text", text: `Failed to update environment: ${error.message}` }] };
1539
- }
1540
- });
1541
- adminToolsCount++;
1542
- server.tool("admin-create-environment-check", "Add an approval, business hours, branch control, or other check to an environment. Supports all Azure DevOps check types. (requires AZUREDEVOPS_ENABLE_ENVIRONMENT_UPSERT=true)", {
1543
- project: z.string().describe("The project name"),
1544
- environmentId: z.number().describe("The environment ID"),
1545
- checkType: z.enum([
1546
- "Approval",
1547
- "BusinessHours",
1548
- "BranchControl",
1549
- "InvokeRESTAPI",
1550
- "InvokeAzureFunction",
1551
- "ExclusiveLock",
1552
- "RequiredTemplate"
1553
- ]).describe("Check type"),
1554
- settings: z.any().describe("Check-specific settings. For Approval: {approvers: [{id: 'user-guid'}], minRequiredApprovers: 1, instructions: '...'}. For BusinessHours: {businessHours: {startTime: '09:00', endTime: '17:00', timeZoneId: 'UTC'}}. For BranchControl: {allowedBranches: ['refs/heads/main']}"),
1555
- timeout: z.number().optional().describe("Timeout in minutes (default: 43200 = 30 days)"),
1556
- }, async ({ project, environmentId, checkType, settings, timeout }) => {
1557
- try {
1558
- const service = getAzureDevOpsService();
1559
- const configuration = { ...settings };
1560
- if (timeout)
1561
- configuration.timeout = timeout;
1562
- const result = await service.addEnvironmentCheck(project, environmentId, checkType, configuration);
1563
- return { content: [{ type: "text", text: `Created ${checkType} check on environment ${environmentId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1564
- }
1565
- catch (error) {
1566
- console.error("Error creating environment check:", error);
1567
- return { content: [{ type: "text", text: `Failed to create environment check: ${error.message}` }] };
1568
- }
1569
- });
1570
- adminToolsCount++;
1571
- server.tool("admin-update-environment-check", "Update an existing environment check's settings or timeout. (requires AZUREDEVOPS_ENABLE_ENVIRONMENT_UPSERT=true)", {
1572
- project: z.string().describe("The project name"),
1573
- checkId: z.number().describe("The check configuration ID to update"),
1574
- settings: z.any().optional().describe("Updated check-specific settings"),
1575
- timeout: z.number().optional().describe("Updated timeout in minutes"),
1576
- }, async ({ project, checkId, settings, timeout }) => {
1577
- try {
1578
- const service = getAzureDevOpsService();
1579
- const updates = {};
1580
- if (settings !== undefined)
1581
- updates.settings = settings;
1582
- if (timeout !== undefined)
1583
- updates.timeout = timeout;
1584
- const result = await service.updateEnvironmentCheck(project, checkId, updates);
1585
- return { content: [{ type: "text", text: `Updated check ${checkId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1586
- }
1587
- catch (error) {
1588
- console.error("Error updating environment check:", error);
1589
- return { content: [{ type: "text", text: `Failed to update environment check: ${error.message}` }] };
1590
- }
1591
- });
1592
- adminToolsCount++;
1593
- }
1149
+ server.tool("sync-work-item-to-file", descWithExamples("Download work item(s) from ADO and save as local markdown file(s). Token-efficient for editing. Auto-converts HTML fields to markdown. Can also pull all child work items under a parent (e.g., all User Stories under a Feature).", SYNC_TO_FILE_EXAMPLES), {
1150
+ project: z.string().describe("The project name"),
1151
+ workItemIds: z.array(z.number()).default([]).describe("Work item IDs to pull (optional if using parentId)"),
1152
+ parentId: z.number().optional().describe("Pull all child work items of this parent (e.g., Feature ID to pull all User Stories)"),
1153
+ childType: z.string().optional().describe("Filter by work item type when using parentId (default: 'User Story'). Common values: 'User Story', 'Bug', 'Task'"),
1154
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1155
+ includeComments: z.boolean().optional().describe("Also save comments to {id}-comments.md (default: false)"),
1156
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion. Only use when explicitly requested. Default: false (auto-convert enabled)"),
1157
+ }, async ({ project, workItemIds: providedWorkItemIds, parentId, childType, folder, includeComments, skipAutoConvert }) => {
1158
+ try {
1159
+ const service = getAzureDevOpsService();
1160
+ const syncConfig = getSyncConfig(folder);
1161
+ // Validate folder path for security
1162
+ validateFolderPath(syncConfig.folder);
1163
+ // Determine which work items to pull
1164
+ let workItemIds = providedWorkItemIds || [];
1165
+ // If parentId is provided, query for child work items
1166
+ if (parentId) {
1167
+ const type = childType || 'User Story';
1168
+ const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = '${type}' ORDER BY [System.Id] ASC`;
1169
+ const queryResult = await service.queryWorkItems(project, wiql, 200);
1170
+ if (queryResult.workItems && queryResult.workItems.length > 0) {
1171
+ const childIds = queryResult.workItems.map((wi) => wi.id);
1172
+ workItemIds = [...workItemIds, ...childIds];
1173
+ }
1174
+ }
1175
+ // Validate that we have work items to pull
1176
+ if (workItemIds.length === 0) {
1177
+ return {
1178
+ content: [{
1179
+ type: "text",
1180
+ text: parentId
1181
+ ? `No ${childType || 'User Story'} work items found under parent #${parentId}`
1182
+ : "No work item IDs provided. Specify workItemIds or parentId.",
1183
+ }],
1184
+ };
1185
+ }
1186
+ // Remove duplicates
1187
+ workItemIds = [...new Set(workItemIds)];
1188
+ const pulled = [];
1189
+ const skipped = [];
1190
+ const commentsFiles = [];
1191
+ const filesToCommit = [];
1192
+ await ensureFolderExists(syncConfig.folder);
1193
+ for (const workItemId of workItemIds) {
1194
+ try {
1195
+ // Fetch work item from ADO
1196
+ let workItem = await service.getWorkItem(project, workItemId);
1197
+ let revision = workItem.rev || workItem._rev || 1;
1198
+ let convertedFields = [];
1199
+ // Check field formats
1200
+ const formats = checkFieldFormats(workItem);
1201
+ if (!formats.ready) {
1202
+ if (skipAutoConvert) {
1203
+ // User explicitly requested to skip conversion
1204
+ const htmlFields = [];
1205
+ if (formats.description === 'html')
1206
+ htmlFields.push('Description');
1207
+ if (formats.acceptanceCriteria === 'html')
1208
+ htmlFields.push('Acceptance Criteria');
1209
+ skipped.push({
1210
+ id: workItemId,
1211
+ reason: `HTML fields: ${htmlFields.join(', ')}. skipAutoConvert=true, skipping.`,
1212
+ });
1213
+ continue;
1214
+ }
1215
+ // Auto-convert HTML fields to markdown in ADO
1216
+ const fieldsToConvert = [];
1217
+ if (formats.description === 'html')
1218
+ fieldsToConvert.push('System.Description');
1219
+ if (formats.acceptanceCriteria === 'html')
1220
+ fieldsToConvert.push('Microsoft.VSTS.Common.AcceptanceCriteria');
1221
+ try {
1222
+ convertedFields = await autoConvertFieldsToMarkdown(service, project, workItemId, workItem.fields, fieldsToConvert);
1223
+ // Re-fetch work item with converted content
1224
+ workItem = await service.getWorkItem(project, workItemId);
1225
+ revision = workItem.rev || workItem._rev || 1;
1226
+ }
1227
+ catch (convertError) {
1228
+ skipped.push({
1229
+ id: workItemId,
1230
+ reason: `Failed to auto-convert HTML: ${convertError.message}`,
1231
+ });
1232
+ continue;
1233
+ }
1234
+ }
1235
+ // Convert to markdown and save
1236
+ const { content: markdown, skippedFields: secondarySkipped } = workItemToMarkdown(workItem, revision);
1237
+ const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
1238
+ await writeWorkItemFile(filePath, markdown);
1239
+ pulled.push({
1240
+ id: workItemId,
1241
+ file: filePath,
1242
+ revision,
1243
+ ...(convertedFields.length > 0 ? { converted: convertedFields } : {}),
1244
+ ...(secondarySkipped.length > 0 ? { skippedFields: secondarySkipped } : {}),
1245
+ });
1246
+ filesToCommit.push({ filePath, workItemId });
1247
+ // Optionally save comments
1248
+ if (includeComments) {
1249
+ const comments = await service.getWorkItemComments(project, workItemId);
1250
+ const commentsMarkdown = commentsToMarkdown(workItem, comments.comments || []);
1251
+ const commentsPath = getCommentsFilePath(syncConfig.folder, workItemId);
1252
+ await writeWorkItemFile(commentsPath, commentsMarkdown);
1253
+ commentsFiles.push({
1254
+ id: workItemId,
1255
+ file: commentsPath,
1256
+ count: (comments.comments || []).length,
1257
+ });
1258
+ filesToCommit.push({ filePath: commentsPath, workItemId });
1259
+ }
1260
+ }
1261
+ catch (error) {
1262
+ skipped.push({ id: workItemId, reason: error.message });
1263
+ }
1264
+ }
1265
+ // Auto-commit if enabled
1266
+ let committed = false;
1267
+ if (syncConfig.autoCommit && filesToCommit.length > 0) {
1268
+ const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced');
1269
+ committed = commitResult.committed;
1270
+ }
1271
+ const result = {
1272
+ pulled,
1273
+ skipped,
1274
+ ...(includeComments ? { commentsFiles } : {}),
1275
+ folder: syncConfig.folder,
1276
+ committed,
1277
+ };
1278
+ return {
1279
+ content: [{
1280
+ type: "text",
1281
+ text: `Synced ${pulled.length} work item(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
1282
+ }],
1283
+ };
1284
+ }
1285
+ catch (error) {
1286
+ console.error("Error syncing work items to files:", error);
1287
+ return {
1288
+ content: [{
1289
+ type: "text",
1290
+ text: `Failed to sync work items: ${error.message}`,
1291
+ }],
1292
+ };
1293
+ }
1294
+ });
1295
+ server.tool("sync-work-item-from-file", descWithExamples("Upload local markdown changes back to ADO. Auto-detects new_*.md files and creates them as new work items. Auto-converts HTML fields to markdown. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", SYNC_FROM_FILE_EXAMPLES), {
1296
+ project: z.string().describe("The project name"),
1297
+ workItemIds: z.array(z.number()).default([]).describe("Work item IDs to push (optional - new_*.md files are auto-detected)"),
1298
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1299
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion. Only use when explicitly requested. Default: false (auto-convert enabled)"),
1300
+ }, async ({ project, workItemIds, folder, skipAutoConvert }) => {
1301
+ try {
1302
+ const service = getAzureDevOpsService();
1303
+ const syncConfig = getSyncConfig(folder);
1304
+ // Validate folder path for security
1305
+ validateFolderPath(syncConfig.folder);
1306
+ const pushed = [];
1307
+ const partial = [];
1308
+ const created = [];
1309
+ const failed = [];
1310
+ // Step 1: Auto-detect and process new_*.md files
1311
+ const newFiles = await findNewWorkItemFiles(syncConfig.folder);
1312
+ for (const filePath of newFiles) {
1313
+ try {
1314
+ // Parse the new work item file
1315
+ const content = await readFileContent(filePath);
1316
+ const parsed = parseNewWorkItemMarkdown(content);
1317
+ // Fetch parent work item to inherit area/iteration paths
1318
+ const parentWorkItem = await service.getWorkItem(project, parsed.frontmatter.parent);
1319
+ // Build fields for creation
1320
+ const fields = buildNewWorkItemFields(parsed, parentWorkItem);
1321
+ // Create the work item in ADO with parent link
1322
+ const createdWorkItem = await service.createWorkItem(project, parsed.frontmatter.type || 'User Story', fields, parsed.frontmatter.parent);
1323
+ const newId = createdWorkItem.id;
1324
+ const revision = createdWorkItem.rev || createdWorkItem._rev || 1;
1325
+ const url = createdWorkItem._links?.html?.href || `https://dev.azure.com/_workitems/edit/${newId}`;
1326
+ // Convert the file content to synced format
1327
+ const syncedContent = convertNewFileToSynced(content, newId, revision, url);
1328
+ // Rename file from new_*.md to {id}.md
1329
+ const newFilePath = getWorkItemFilePath(syncConfig.folder, newId);
1330
+ await writeWorkItemFile(newFilePath, syncedContent);
1331
+ await renameFile(filePath, filePath + '.created'); // Mark original as processed
1332
+ // Actually delete the .created file (cleanup)
1333
+ try {
1334
+ const fs = await import('node:fs/promises');
1335
+ await fs.unlink(filePath + '.created');
1336
+ }
1337
+ catch {
1338
+ // Ignore cleanup errors
1339
+ }
1340
+ created.push({
1341
+ id: newId,
1342
+ oldFile: filePath,
1343
+ newFile: newFilePath,
1344
+ parentId: parsed.frontmatter.parent,
1345
+ });
1346
+ }
1347
+ catch (error) {
1348
+ failed.push({ file: filePath, error: error.message });
1349
+ }
1350
+ }
1351
+ // Step 2: Process existing work items (if workItemIds provided)
1352
+ const idsToProcess = workItemIds || [];
1353
+ for (const workItemId of idsToProcess) {
1354
+ const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
1355
+ try {
1356
+ // Check if file exists
1357
+ if (!await fileExists(filePath)) {
1358
+ failed.push({ id: workItemId, error: `File not found: ${filePath}` });
1359
+ continue;
1360
+ }
1361
+ // Parse local file
1362
+ const parsed = await readWorkItemFile(filePath);
1363
+ const oldRevision = parsed.frontmatter.lastSyncedRevision;
1364
+ // Fetch current work item from ADO to compare
1365
+ const currentWorkItem = await service.getWorkItem(project, workItemId);
1366
+ // Build patch operations (auto-converts HTML fields unless skipAutoConvert)
1367
+ const { operations, skippedFields, convertedFields } = buildPatchOperations(parsed, currentWorkItem, skipAutoConvert);
1368
+ if (operations.length === 0 && skippedFields.length === 0) {
1369
+ // No changes
1370
+ pushed.push({
1371
+ id: workItemId,
1372
+ oldRevision,
1373
+ newRevision: currentWorkItem.rev || currentWorkItem._rev || oldRevision,
1374
+ fieldsUpdated: [],
1375
+ });
1376
+ continue;
1377
+ }
1378
+ // Update ADO if there are operations
1379
+ let newRevision = oldRevision;
1380
+ let fieldsUpdated = [];
1381
+ if (operations.length > 0) {
1382
+ const updatedWorkItem = await service.updateWorkItem(project, workItemId, operations);
1383
+ newRevision = updatedWorkItem.rev || updatedWorkItem._rev || oldRevision + 1;
1384
+ // Filter out format operations from fieldsUpdated
1385
+ fieldsUpdated = operations
1386
+ .filter(op => op.path.startsWith('/fields/'))
1387
+ .map(op => op.path.replace('/fields/', ''));
1388
+ // Update local file with new revision
1389
+ const content = await readFileContent(filePath);
1390
+ const updatedContent = updateSyncRevision(content, newRevision);
1391
+ await writeWorkItemFile(filePath, updatedContent);
1392
+ }
1393
+ if (skippedFields.length > 0) {
1394
+ partial.push({
1395
+ id: workItemId,
1396
+ oldRevision,
1397
+ newRevision,
1398
+ fieldsUpdated,
1399
+ skippedFields,
1400
+ ...(convertedFields.length > 0 ? { convertedFields } : {}),
1401
+ });
1402
+ }
1403
+ else {
1404
+ pushed.push({
1405
+ id: workItemId,
1406
+ oldRevision,
1407
+ newRevision,
1408
+ fieldsUpdated,
1409
+ ...(convertedFields.length > 0 ? { convertedFields } : {}),
1410
+ });
1411
+ }
1412
+ }
1413
+ catch (error) {
1414
+ failed.push({ id: workItemId, error: error.message });
1415
+ }
1416
+ }
1417
+ const result = {
1418
+ created,
1419
+ pushed,
1420
+ partial,
1421
+ failed,
1422
+ folder: syncConfig.folder,
1423
+ };
1424
+ const summary = [];
1425
+ if (created.length > 0)
1426
+ summary.push(`Created ${created.length} new work item(s)`);
1427
+ if (pushed.length > 0)
1428
+ summary.push(`Updated ${pushed.length} work item(s)`);
1429
+ if (partial.length > 0)
1430
+ summary.push(`Partially updated ${partial.length} work item(s)`);
1431
+ if (failed.length > 0)
1432
+ summary.push(`Failed: ${failed.length}`);
1433
+ return {
1434
+ content: [{
1435
+ type: "text",
1436
+ text: `${summary.join(', ')}:\n\n${JSON.stringify(result, null, 2)}`,
1437
+ }],
1438
+ };
1439
+ }
1440
+ catch (error) {
1441
+ console.error("Error syncing work items from files:", error);
1442
+ return {
1443
+ content: [{
1444
+ type: "text",
1445
+ text: `Failed to push work items: ${error.message}`,
1446
+ }],
1447
+ };
1448
+ }
1449
+ });
1450
+ server.tool("check-work-item-markdown", "Check if work item fields are markdown (required for sync) or HTML format.", {
1451
+ project: z.string().describe("The project name"),
1452
+ workItemIds: z.array(z.number()).describe("Work item IDs to check"),
1453
+ }, async ({ project, workItemIds }) => {
1454
+ try {
1455
+ const service = getAzureDevOpsService();
1456
+ const results = [];
1457
+ let readyCount = 0;
1458
+ let needsConversionCount = 0;
1459
+ for (const workItemId of workItemIds) {
1460
+ try {
1461
+ const workItem = await service.getWorkItem(project, workItemId);
1462
+ const formats = checkFieldFormats(workItem);
1463
+ results.push({
1464
+ id: workItemId,
1465
+ description: formats.description,
1466
+ acceptanceCriteria: formats.acceptanceCriteria,
1467
+ howToTest: formats.additionalFields.howToTest,
1468
+ predeploymentSteps: formats.additionalFields.predeploymentSteps,
1469
+ postdeploymentSteps: formats.additionalFields.postdeploymentSteps,
1470
+ deploymentInformation: formats.additionalFields.deploymentInformation,
1471
+ ready: formats.ready,
1472
+ ...(formats.warnings.length > 0 ? { warnings: formats.warnings } : {}),
1473
+ });
1474
+ if (formats.ready) {
1475
+ readyCount++;
1476
+ }
1477
+ else {
1478
+ needsConversionCount++;
1479
+ }
1480
+ }
1481
+ catch (error) {
1482
+ results.push({
1483
+ id: workItemId,
1484
+ description: 'error',
1485
+ acceptanceCriteria: 'error',
1486
+ ready: false,
1487
+ error: error.message || String(error),
1488
+ });
1489
+ needsConversionCount++;
1490
+ }
1491
+ }
1492
+ const result = {
1493
+ results,
1494
+ summary: {
1495
+ ready: readyCount,
1496
+ needsConversion: needsConversionCount,
1497
+ },
1498
+ autoConvertAvailable: true,
1499
+ message: needsConversionCount === 0
1500
+ ? 'All work items are markdown format - ready to sync'
1501
+ : `${needsConversionCount} work item(s) have HTML fields. Will be auto-converted to markdown on sync (unless skipAutoConvert=true).`,
1502
+ };
1503
+ return {
1504
+ content: [{
1505
+ type: "text",
1506
+ text: `Work item format check:\n\n${JSON.stringify(result, null, 2)}`,
1507
+ }],
1508
+ };
1509
+ }
1510
+ catch (error) {
1511
+ console.error("Error checking work item formats:", error);
1512
+ return {
1513
+ content: [{
1514
+ type: "text",
1515
+ text: `Failed to check work item formats: ${error.message}`,
1516
+ }],
1517
+ };
1518
+ }
1519
+ });
1520
+ server.tool("list-synced-work-items", "List work items that have been synced to local markdown files.", {
1521
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1522
+ }, async ({ folder }) => {
1523
+ try {
1524
+ const syncConfig = getSyncConfig(folder);
1525
+ // Validate folder path for security
1526
+ validateFolderPath(syncConfig.folder);
1527
+ const workItems = await listSyncedWorkItems(syncConfig.folder);
1528
+ const result = {
1529
+ workItems,
1530
+ folder: syncConfig.folder,
1531
+ count: workItems.length,
1532
+ };
1533
+ return {
1534
+ content: [{
1535
+ type: "text",
1536
+ text: `Synced work items in ${syncConfig.folder}:\n\n${JSON.stringify(result, null, 2)}`,
1537
+ }],
1538
+ };
1539
+ }
1540
+ catch (error) {
1541
+ console.error("Error listing synced work items:", error);
1542
+ return {
1543
+ content: [{
1544
+ type: "text",
1545
+ text: `Failed to list synced work items: ${error.message}`,
1546
+ }],
1547
+ };
1548
+ }
1549
+ });
1550
+ server.tool("create-user-story-file", "Create a new user story template file locally. The file can be edited and then pushed to ADO using sync-work-item-from-file.", {
1551
+ project: z.string().describe("The project name"),
1552
+ parentId: z.number().describe("Parent Feature ID - the new user story will be created under this feature"),
1553
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1554
+ }, async ({ project, parentId, folder }) => {
1555
+ try {
1556
+ const service = getAzureDevOpsService();
1557
+ const syncConfig = getSyncConfig(folder);
1558
+ // Validate folder path for security
1559
+ validateFolderPath(syncConfig.folder);
1560
+ // Fetch parent work item to get title
1561
+ const parentWorkItem = await service.getWorkItem(project, parentId);
1562
+ const parentTitle = parentWorkItem.fields?.['System.Title'] || '';
1563
+ // Find next available index for new file
1564
+ const nextIndex = await findNextNewFileIndex(syncConfig.folder, parentId);
1565
+ const filePath = getNewWorkItemFilePath(syncConfig.folder, parentId, nextIndex);
1566
+ // Generate template content
1567
+ const template = generateNewWorkItemTemplate(parentId, parentTitle, project, 'User Story');
1568
+ // Ensure folder exists and write file
1569
+ await ensureFolderExists(syncConfig.folder);
1570
+ await writeWorkItemFile(filePath, template);
1571
+ const result = {
1572
+ file: filePath,
1573
+ parentId,
1574
+ parentTitle,
1575
+ instructions: [
1576
+ `1. Edit the file to update title, description, and acceptance criteria`,
1577
+ `2. Run sync-work-item-from-file(project: "${project}") to create in ADO`,
1578
+ `3. The file will be renamed to {newId}.md after creation`,
1579
+ ],
1580
+ };
1581
+ return {
1582
+ content: [{
1583
+ type: "text",
1584
+ text: `Created new user story template:\n\n${JSON.stringify(result, null, 2)}`,
1585
+ }],
1586
+ };
1587
+ }
1588
+ catch (error) {
1589
+ console.error("Error creating user story file:", error);
1590
+ return {
1591
+ content: [{
1592
+ type: "text",
1593
+ text: `Failed to create user story file: ${error.message}`,
1594
+ }],
1595
+ };
1596
+ }
1597
+ });
1594
1598
  // ========================================
1595
- // ENVIRONMENT DELETE TOOLS (Tier 3)
1599
+ // TASK SYNC TOOLS
1596
1600
  // ========================================
1597
- if (enableEnvironmentDelete) {
1598
- server.tool("admin-delete-environment", "⚠️ DESTRUCTIVE: Delete a deployment environment. Pipelines targeting this environment will fail. (requires AZUREDEVOPS_ENABLE_ENVIRONMENT_DELETE=true)", {
1599
- project: z.string().describe("The project name"),
1600
- environmentId: z.number().describe("The environment ID to delete"),
1601
- }, async ({ project, environmentId }) => {
1602
- try {
1603
- const service = getAzureDevOpsService();
1604
- const result = await service.deleteEnvironment(project, environmentId);
1605
- return { content: [{ type: "text", text: `Deleted environment ${environmentId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1606
- }
1607
- catch (error) {
1608
- console.error("Error deleting environment:", error);
1609
- return { content: [{ type: "text", text: `Failed to delete environment: ${error.message}` }] };
1610
- }
1611
- });
1612
- adminToolsCount++;
1613
- server.tool("admin-delete-environment-check", "Delete an approval, business hours, branch control, or other check from an environment. (requires AZUREDEVOPS_ENABLE_ENVIRONMENT_DELETE=true)", {
1614
- project: z.string().describe("The project name"),
1615
- checkId: z.number().describe("The check configuration ID to delete (get from get-environment-checks)"),
1616
- }, async ({ project, checkId }) => {
1617
- try {
1618
- const service = getAzureDevOpsService();
1619
- const result = await service.removeEnvironmentCheck(project, checkId);
1620
- return { content: [{ type: "text", text: `Deleted check ${checkId}:\n\n${JSON.stringify(result, null, 2)}` }] };
1621
- }
1622
- catch (error) {
1623
- console.error("Error deleting environment check:", error);
1624
- return { content: [{ type: "text", text: `Failed to delete environment check: ${error.message}` }] };
1625
- }
1626
- });
1627
- adminToolsCount++;
1628
- }
1629
- // Log registration summary
1630
- const baseToolsCount = 15;
1631
- const totalToolsCount = baseToolsCount + adminToolsCount;
1632
- console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${adminToolsCount} admin), 4 prompts`);
1601
+ server.tool("sync-tasks-to-file", descWithExamples("Download all tasks under a parent work item (User Story) to a local markdown file. Auto-converts HTML descriptions to markdown. Supports pulling tasks for multiple parents at once.", SYNC_TASKS_TO_FILE_EXAMPLES), {
1602
+ project: z.string().describe("The project name"),
1603
+ parentIds: z.array(z.number()).describe("Parent work item IDs (User Stories) to fetch tasks for"),
1604
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1605
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion for task descriptions. Only use when explicitly requested. Default: false"),
1606
+ }, async ({ project, parentIds, folder, skipAutoConvert }) => {
1607
+ try {
1608
+ const service = getAzureDevOpsService();
1609
+ const syncConfig = getSyncConfig(folder);
1610
+ // Validate folder path for security
1611
+ validateFolderPath(syncConfig.folder);
1612
+ const pulled = [];
1613
+ const failed = [];
1614
+ const filesToCommit = [];
1615
+ await ensureFolderExists(syncConfig.folder);
1616
+ for (const parentId of parentIds) {
1617
+ try {
1618
+ // Fetch parent work item to get title and validate existence
1619
+ const parentWorkItem = await service.getWorkItem(project, parentId);
1620
+ // Query for child tasks using WIQL
1621
+ const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = 'Task' ORDER BY [System.Id] ASC`;
1622
+ const queryResult = await service.queryWorkItems(project, wiql, 200);
1623
+ // Fetch full details for each task and auto-convert HTML descriptions
1624
+ const tasks = [];
1625
+ let tasksConverted = 0;
1626
+ if (queryResult.workItems && queryResult.workItems.length > 0) {
1627
+ for (const wi of queryResult.workItems) {
1628
+ try {
1629
+ let task = await service.getWorkItem(project, wi.id);
1630
+ // Auto-convert HTML description to markdown unless skipAutoConvert
1631
+ const description = task.fields?.['System.Description'];
1632
+ if (description && !skipAutoConvert && isHtmlContent(description)) {
1633
+ try {
1634
+ await autoConvertFieldsToMarkdown(service, project, wi.id, task.fields, ['System.Description']);
1635
+ // Re-fetch with converted content
1636
+ task = await service.getWorkItem(project, wi.id);
1637
+ tasksConverted++;
1638
+ }
1639
+ catch (convertError) {
1640
+ console.error(`Failed to convert HTML description for task ${wi.id}:`, convertError.message);
1641
+ }
1642
+ }
1643
+ tasks.push(task);
1644
+ }
1645
+ catch (taskError) {
1646
+ console.error(`Error fetching task ${wi.id}:`, taskError.message);
1647
+ }
1648
+ }
1649
+ }
1650
+ // Convert to markdown and save
1651
+ const markdown = tasksToMarkdown(parentWorkItem, tasks, project);
1652
+ const filePath = getTasksFilePath(syncConfig.folder, parentId);
1653
+ await writeWorkItemFile(filePath, markdown);
1654
+ pulled.push({
1655
+ parentId,
1656
+ file: filePath,
1657
+ taskCount: tasks.length,
1658
+ });
1659
+ filesToCommit.push({ filePath, workItemId: parentId });
1660
+ }
1661
+ catch (error) {
1662
+ failed.push({ parentId, error: error.message });
1663
+ }
1664
+ }
1665
+ // Auto-commit if enabled
1666
+ let committed = false;
1667
+ if (syncConfig.autoCommit && filesToCommit.length > 0) {
1668
+ const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced tasks for');
1669
+ committed = commitResult.committed;
1670
+ }
1671
+ const result = {
1672
+ pulled,
1673
+ failed,
1674
+ folder: syncConfig.folder,
1675
+ committed,
1676
+ };
1677
+ return {
1678
+ content: [{
1679
+ type: "text",
1680
+ text: `Synced tasks for ${pulled.length} parent(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
1681
+ }],
1682
+ };
1683
+ }
1684
+ catch (error) {
1685
+ console.error("Error syncing tasks to files:", error);
1686
+ return {
1687
+ content: [{
1688
+ type: "text",
1689
+ text: `Failed to sync tasks: ${error.message}`,
1690
+ }],
1691
+ };
1692
+ }
1693
+ });
1694
+ server.tool("sync-tasks-from-file", descWithExamples("Push local task changes back to ADO with upsert semantics. Existing tasks (## Task #ID) are updated, new tasks (## NEW TASK) are created. Auto-converts HTML fields to markdown. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", SYNC_TASKS_FROM_FILE_EXAMPLES), {
1695
+ project: z.string().describe("The project name"),
1696
+ parentIds: z.array(z.number()).describe("Parent work item IDs to sync tasks for"),
1697
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1698
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion for task descriptions. Only use when explicitly requested. Default: false"),
1699
+ }, async ({ project, parentIds, folder, skipAutoConvert }) => {
1700
+ try {
1701
+ const service = getAzureDevOpsService();
1702
+ const syncConfig = getSyncConfig(folder);
1703
+ // Validate folder path for security
1704
+ validateFolderPath(syncConfig.folder);
1705
+ const updated = [];
1706
+ const created = [];
1707
+ const failed = [];
1708
+ for (const parentId of parentIds) {
1709
+ const filePath = getTasksFilePath(syncConfig.folder, parentId);
1710
+ try {
1711
+ // Check if file exists
1712
+ if (!await fileExists(filePath)) {
1713
+ failed.push({ parentId, error: `File not found: ${filePath}` });
1714
+ continue;
1715
+ }
1716
+ // Parse local file
1717
+ const content = await readFileContent(filePath);
1718
+ const parsed = parseTasksMarkdown(content);
1719
+ // Get parent work item for area/iteration path
1720
+ const parentWorkItem = await service.getWorkItem(project, parentId);
1721
+ const parentFields = parentWorkItem.fields || {};
1722
+ const areaPath = parentFields['System.AreaPath'];
1723
+ const iterationPath = parentFields['System.IterationPath'];
1724
+ const createdInThisFile = [];
1725
+ // Process each task
1726
+ for (const task of parsed.tasks) {
1727
+ try {
1728
+ if (task.id !== null) {
1729
+ // Existing task - update (auto-converts HTML fields unless skipAutoConvert)
1730
+ const currentTask = await service.getWorkItem(project, task.id);
1731
+ const { operations, fieldsUpdated, convertedFields } = buildTaskPatchOperations(task, currentTask, skipAutoConvert);
1732
+ if (operations.length > 0) {
1733
+ await service.updateWorkItem(project, task.id, operations);
1734
+ updated.push({
1735
+ id: task.id,
1736
+ parentId,
1737
+ fieldsUpdated,
1738
+ ...(convertedFields.length > 0 ? { convertedFields } : {}),
1739
+ });
1740
+ }
1741
+ else {
1742
+ // No changes needed
1743
+ updated.push({
1744
+ id: task.id,
1745
+ parentId,
1746
+ fieldsUpdated: [],
1747
+ });
1748
+ }
1749
+ }
1750
+ else {
1751
+ // New task - create
1752
+ if (!task.title) {
1753
+ continue; // Skip tasks without title
1754
+ }
1755
+ const fields = buildNewTaskFields(task, areaPath, iterationPath);
1756
+ const createdTask = await service.createWorkItem(project, 'Task', fields, parentId);
1757
+ created.push({
1758
+ id: createdTask.id,
1759
+ parentId,
1760
+ title: task.title,
1761
+ });
1762
+ createdInThisFile.push({
1763
+ title: task.title,
1764
+ id: createdTask.id,
1765
+ });
1766
+ }
1767
+ }
1768
+ catch (taskError) {
1769
+ failed.push({
1770
+ parentId,
1771
+ taskId: task.id || undefined,
1772
+ error: taskError.message,
1773
+ });
1774
+ }
1775
+ }
1776
+ // Update the file with new task IDs if any were created
1777
+ if (createdInThisFile.length > 0) {
1778
+ const updatedContent = updateTasksFileAfterCreate(content, createdInThisFile);
1779
+ await writeWorkItemFile(filePath, updatedContent);
1780
+ }
1781
+ }
1782
+ catch (error) {
1783
+ failed.push({ parentId, error: error.message });
1784
+ }
1785
+ }
1786
+ const result = {
1787
+ updated,
1788
+ created,
1789
+ failed,
1790
+ folder: syncConfig.folder,
1791
+ };
1792
+ return {
1793
+ content: [{
1794
+ type: "text",
1795
+ text: `Pushed tasks to ADO (${updated.length} updated, ${created.length} created):\n\n${JSON.stringify(result, null, 2)}`,
1796
+ }],
1797
+ };
1798
+ }
1799
+ catch (error) {
1800
+ console.error("Error syncing tasks from files:", error);
1801
+ return {
1802
+ content: [{
1803
+ type: "text",
1804
+ text: `Failed to push tasks: ${error.message}`,
1805
+ }],
1806
+ };
1807
+ }
1808
+ });
1809
+ // Log registration summary (enablePullRequestWrite already defined above)
1810
+ // Tool count breakdown:
1811
+ // - Wiki: 6 (get-wikis, search-wiki-pages, get-wiki-page, create-wiki-page, update-wiki-page, str-replace-wiki-page)
1812
+ // - Work Item: 7 (get-work-item, query-work-items, get-work-item-comments, add-work-item-comment, update-work-item, create-work-item, delete-work-item)
1813
+ // - Variable Group: 2 (list-variable-groups, get-variable-group)
1814
+ // - PR Read-only: 6 (list-repositories, list-pull-requests, get-pull-request, get-pull-request-threads, get-pull-request-commits, get-pull-request-changes)
1815
+ // - Build Troubleshooting: 3 (get-build-status, get-build-timeline, get-build-logs)
1816
+ // - Work Item Sync: 5 (sync-work-item-to-file, sync-work-item-from-file, check-work-item-markdown, list-synced-work-items, create-user-story-file)
1817
+ // - Task Sync: 2 (sync-tasks-to-file, sync-tasks-from-file)
1818
+ // - Config: 1 (get-configuration)
1819
+ // Total base: 6 + 7 + 2 + 6 + 3 + 5 + 2 + 1 = 32
1820
+ // + PR Write: 7 (add-pull-request-thread, create-pull-request, update-pull-request, complete-pull-request, add-pr-reviewer, vote-pull-request, reply-to-pr-thread) - conditional
1821
+ const baseToolsCount = 32;
1822
+ const prWriteToolsCount = enablePullRequestWrite ? 7 : 0;
1823
+ const totalToolsCount = baseToolsCount + prWriteToolsCount;
1824
+ console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${prWriteToolsCount} PR write), 4 prompts`);
1825
+ // NOTE: Admin tools (pipelines, service connections, agent pools, environments)
1826
+ // have been moved to the @mcp-consultant-tools/azure-devops-admin package
1633
1827
  }
1634
1828
  /**
1635
1829
  * Export service class for direct usage