@mcp-consultant-tools/azure-devops 27.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 +62 -0
  2. package/build/AzureDevOpsService.d.ts.map +1 -1
  3. package/build/AzureDevOpsService.js +395 -2
  4. package/build/AzureDevOpsService.js.map +1 -1
  5. package/build/index.d.ts.map +1 -1
  6. package/build/index.js +930 -13
  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 +3 -1
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
@@ -316,6 +324,45 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
316
324
  // ========================================
317
325
  // TOOLS
318
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
+ });
319
366
  server.tool("get-wikis", "Get all wikis in an Azure DevOps project", {
320
367
  project: z.string().describe("The project name"),
321
368
  }, async ({ project }) => {
@@ -469,7 +516,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
469
516
  };
470
517
  }
471
518
  });
472
- 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)", {
473
520
  project: z.string().describe("The project name"),
474
521
  wikiId: z.string().describe("The wiki identifier (ID or name)"),
475
522
  pagePath: z.string().describe("The path to the wiki page (e.g., '/SharePoint-Online/04-DEV-Configuration')"),
@@ -535,7 +582,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
535
582
  });
536
583
  server.tool("query-work-items", "Query work items using WIQL (Work Item Query Language) in Azure DevOps", {
537
584
  project: z.string().describe("The project name"),
538
- 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)),
539
586
  maxResults: z.number().optional().describe("Maximum number of results (default: 200)"),
540
587
  }, async ({ project, wiql, maxResults }) => {
541
588
  try {
@@ -622,24 +669,53 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
622
669
  };
623
670
  }
624
671
  });
625
- 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.", {
626
673
  project: z.string().describe("The project name"),
627
674
  workItemId: z.number().describe("The work item ID"),
628
675
  patchOperations: z.array(z.object({
629
- op: z.string().describe("The operation type (e.g., 'add', 'replace', 'remove')"),
630
- 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')"),
631
678
  value: z.any().optional().describe("The value to set (not required for 'remove' operation)")
632
- })).describe("Array of JSON Patch operations"),
633
- }, 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 }) => {
634
682
  try {
635
683
  const service = getAzureDevOpsService();
636
- 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
+ }
637
713
  const resultStr = JSON.stringify(result, null, 2);
638
714
  return {
639
715
  content: [
640
716
  {
641
717
  type: "text",
642
- text: `Updated work item ${workItemId}:\n\n${resultStr}`,
718
+ text: `${message}:\n\n${resultStr}`,
643
719
  },
644
720
  ],
645
721
  };
@@ -658,8 +734,8 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
658
734
  });
659
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)", {
660
736
  project: z.string().describe("The project name"),
661
- workItemType: z.string().describe("The work item type (e.g., 'Bug', 'Task', 'User Story')"),
662
- 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)),
663
739
  parentId: z.number().optional().describe("Optional parent work item ID (for creating child items). Simplified alternative to relations parameter."),
664
740
  relations: z.array(z.object({
665
741
  rel: z.string().describe("Relation type (e.g., 'System.LinkTypes.Hierarchy-Reverse' for parent)"),
@@ -839,6 +915,120 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
839
915
  return { content: [{ type: "text", text: `Failed to add pull request thread: ${error.message}` }] };
840
916
  }
841
917
  });
918
+ server.tool("create-pull-request", "Create a new pull request in a Git repository. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
919
+ project: z.string().describe("The project name"),
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 }) => {
928
+ try {
929
+ const service = getAzureDevOpsService();
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)}` }] };
932
+ }
933
+ catch (error) {
934
+ console.error("Error creating pull request:", error);
935
+ return { content: [{ type: "text", text: `Failed to create pull request: ${error.message}` }] };
936
+ }
937
+ });
938
+ server.tool("update-pull-request", "Update a pull request's title, description, status, or draft state. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
939
+ project: z.string().describe("The project name"),
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 }) => {
947
+ try {
948
+ const service = getAzureDevOpsService();
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)}` }] };
951
+ }
952
+ catch (error) {
953
+ console.error("Error updating pull request:", error);
954
+ return { content: [{ type: "text", text: `Failed to update pull request: ${error.message}` }] };
955
+ }
956
+ });
957
+ server.tool("complete-pull-request", "Complete (merge) a pull request with configurable merge strategy. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
958
+ project: z.string().describe("The project name"),
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 }) => {
967
+ try {
968
+ const service = getAzureDevOpsService();
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)}` }] };
971
+ }
972
+ catch (error) {
973
+ console.error("Error completing pull request:", error);
974
+ return { content: [{ type: "text", text: `Failed to complete pull request: ${error.message}` }] };
975
+ }
976
+ });
977
+ server.tool("add-pr-reviewer", "Add or remove a reviewer from a pull request. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
978
+ project: z.string().describe("The project name"),
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 }) => {
985
+ try {
986
+ const service = getAzureDevOpsService();
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)}` }] };
989
+ }
990
+ catch (error) {
991
+ console.error("Error managing PR reviewer:", error);
992
+ return { content: [{ type: "text", text: `Failed to manage PR reviewer: ${error.message}` }] };
993
+ }
994
+ });
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)", {
996
+ project: z.string().describe("The project name"),
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 }) => {
1003
+ try {
1004
+ const service = getAzureDevOpsService();
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)}` }] };
1007
+ }
1008
+ catch (error) {
1009
+ console.error("Error voting on pull request:", error);
1010
+ return { content: [{ type: "text", text: `Failed to vote on pull request: ${error.message}` }] };
1011
+ }
1012
+ });
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)", {
1014
+ project: z.string().describe("The project name"),
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 }) => {
1022
+ try {
1023
+ const service = getAzureDevOpsService();
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)}` }] };
1026
+ }
1027
+ catch (error) {
1028
+ console.error("Error replying to PR thread:", error);
1029
+ return { content: [{ type: "text", text: `Failed to reply to PR thread: ${error.message}` }] };
1030
+ }
1031
+ });
842
1032
  }
843
1033
  // ========================================
844
1034
  // VARIABLE GROUP TOOLS
@@ -900,9 +1090,736 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
900
1090
  };
901
1091
  }
902
1092
  });
1093
+ // ========================================
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
1097
+ // ========================================
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
+ });
1146
+ // ========================================
1147
+ // WORK ITEM SYNC TOOLS (Local Markdown)
1148
+ // ========================================
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
+ });
1598
+ // ========================================
1599
+ // TASK SYNC TOOLS
1600
+ // ========================================
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
+ });
903
1809
  // Log registration summary (enablePullRequestWrite already defined above)
904
- const baseToolsCount = 21; // 15 original + 6 PR read-only tools
905
- const prWriteToolsCount = enablePullRequestWrite ? 1 : 0;
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;
906
1823
  const totalToolsCount = baseToolsCount + prWriteToolsCount;
907
1824
  console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${prWriteToolsCount} PR write), 4 prompts`);
908
1825
  // NOTE: Admin tools (pipelines, service connections, agent pools, environments)