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

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.
package/build/index.js CHANGED
@@ -10,6 +10,11 @@ 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 { checkFieldFormats, getConversionInstructions, workItemToMarkdown, commentsToMarkdown, buildPatchOperations, updateSyncRevision, getSyncConfig, ensureFolderExists, getWorkItemFilePath, getCommentsFilePath, fileExists, writeWorkItemFile, readWorkItemFile, readFileContent, listSyncedWorkItems, validateFolderPath, autoCommitMultipleFiles,
14
+ // Task sync utilities
15
+ tasksToMarkdown, parseTasksMarkdown, buildTaskPatchOperations, buildNewTaskFields, getTasksFilePath, updateTasksFileAfterCreate,
16
+ // New work item utilities
17
+ parseNewWorkItemMarkdown, buildNewWorkItemFields, generateNewWorkItemTemplate, convertNewFileToSynced, getNewWorkItemFilePath, findNextNewFileIndex, findNewWorkItemFiles, renameFile, } from './sync/index.js';
13
18
  /**
14
19
  * Register azure-devops tools and prompts to an MCP server
15
20
  * @param server - The MCP server instance
@@ -316,6 +321,45 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
316
321
  // ========================================
317
322
  // TOOLS
318
323
  // ========================================
324
+ server.tool("get-configuration", "Get the configured Azure DevOps organization and projects. Use this to construct correct URLs.", {}, async () => {
325
+ try {
326
+ const organization = process.env.AZUREDEVOPS_ORGANIZATION;
327
+ const projects = process.env.AZUREDEVOPS_PROJECTS?.split(",").map(p => p.trim()).filter(p => p) || [];
328
+ const syncFolder = process.env.AZUREDEVOPS_SYNC_FOLDER || 'docs/user-stories';
329
+ if (!organization || projects.length === 0) {
330
+ return {
331
+ content: [{
332
+ type: "text",
333
+ text: "Azure DevOps not configured. Set AZUREDEVOPS_ORGANIZATION and AZUREDEVOPS_PROJECTS environment variables.",
334
+ }],
335
+ };
336
+ }
337
+ const config = {
338
+ organization,
339
+ projects,
340
+ syncFolder,
341
+ urlPatterns: {
342
+ workItem: `https://dev.azure.com/${organization}/{project}/_workitems/edit/{id}`,
343
+ pullRequest: `https://dev.azure.com/${organization}/{project}/_git/{repo}/pullrequest/{id}`,
344
+ wiki: `https://dev.azure.com/${organization}/{project}/_wiki/wikis/{wikiName}/{pagePath}`,
345
+ },
346
+ };
347
+ return {
348
+ content: [{
349
+ type: "text",
350
+ text: `Azure DevOps Configuration:\n\n${JSON.stringify(config, null, 2)}`,
351
+ }],
352
+ };
353
+ }
354
+ catch (error) {
355
+ return {
356
+ content: [{
357
+ type: "text",
358
+ text: `Failed to get configuration: ${error.message}`,
359
+ }],
360
+ };
361
+ }
362
+ });
319
363
  server.tool("get-wikis", "Get all wikis in an Azure DevOps project", {
320
364
  project: z.string().describe("The project name"),
321
365
  }, async ({ project }) => {
@@ -900,8 +944,622 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
900
944
  };
901
945
  }
902
946
  });
947
+ // ========================================
948
+ // WORK ITEM SYNC TOOLS (Local Markdown)
949
+ // ========================================
950
+ server.tool("sync-work-item-to-file", "Download work item(s) from ADO and save as local markdown file(s). Token-efficient for editing. Requires markdown format fields (not HTML). Can also pull all child work items under a parent (e.g., all User Stories under a Feature).", {
951
+ project: z.string().describe("The project name"),
952
+ workItemIds: z.array(z.number()).optional().describe("Work item IDs to pull (optional if using parentId)"),
953
+ parentId: z.number().optional().describe("Pull all child work items of this parent (e.g., Feature ID to pull all User Stories)"),
954
+ childType: z.string().optional().describe("Filter by work item type when using parentId (default: 'User Story')"),
955
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
956
+ includeComments: z.boolean().optional().describe("Also save comments to {id}-comments.md (default: false)"),
957
+ }, async ({ project, workItemIds: providedWorkItemIds, parentId, childType, folder, includeComments }) => {
958
+ try {
959
+ const service = getAzureDevOpsService();
960
+ const syncConfig = getSyncConfig(folder);
961
+ // Validate folder path for security
962
+ validateFolderPath(syncConfig.folder);
963
+ // Determine which work items to pull
964
+ let workItemIds = providedWorkItemIds || [];
965
+ // If parentId is provided, query for child work items
966
+ if (parentId) {
967
+ const type = childType || 'User Story';
968
+ const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = '${type}' ORDER BY [System.Id] ASC`;
969
+ const queryResult = await service.queryWorkItems(project, wiql, 200);
970
+ if (queryResult.workItems && queryResult.workItems.length > 0) {
971
+ const childIds = queryResult.workItems.map((wi) => wi.id);
972
+ workItemIds = [...workItemIds, ...childIds];
973
+ }
974
+ }
975
+ // Validate that we have work items to pull
976
+ if (workItemIds.length === 0) {
977
+ return {
978
+ content: [{
979
+ type: "text",
980
+ text: parentId
981
+ ? `No ${childType || 'User Story'} work items found under parent #${parentId}`
982
+ : "No work item IDs provided. Specify workItemIds or parentId.",
983
+ }],
984
+ };
985
+ }
986
+ // Remove duplicates
987
+ workItemIds = [...new Set(workItemIds)];
988
+ const pulled = [];
989
+ const skipped = [];
990
+ const commentsFiles = [];
991
+ const filesToCommit = [];
992
+ await ensureFolderExists(syncConfig.folder);
993
+ for (const workItemId of workItemIds) {
994
+ try {
995
+ // Fetch work item from ADO
996
+ const workItem = await service.getWorkItem(project, workItemId);
997
+ const revision = workItem.rev || workItem._rev || 1;
998
+ // Check field formats
999
+ const formats = checkFieldFormats(workItem);
1000
+ if (!formats.ready) {
1001
+ const htmlFields = [];
1002
+ if (formats.description === 'html')
1003
+ htmlFields.push('Description');
1004
+ if (formats.acceptanceCriteria === 'html')
1005
+ htmlFields.push('Acceptance Criteria');
1006
+ skipped.push({
1007
+ id: workItemId,
1008
+ reason: `HTML fields: ${htmlFields.join(', ')}. Convert to markdown in ADO first.`,
1009
+ });
1010
+ continue;
1011
+ }
1012
+ // Convert to markdown and save
1013
+ const { content: markdown, skippedFields: secondarySkipped } = workItemToMarkdown(workItem, revision);
1014
+ const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
1015
+ await writeWorkItemFile(filePath, markdown);
1016
+ pulled.push({
1017
+ id: workItemId,
1018
+ file: filePath,
1019
+ revision,
1020
+ ...(secondarySkipped.length > 0 ? { skippedFields: secondarySkipped } : {}),
1021
+ });
1022
+ filesToCommit.push({ filePath, workItemId });
1023
+ // Optionally save comments
1024
+ if (includeComments) {
1025
+ const comments = await service.getWorkItemComments(project, workItemId);
1026
+ const commentsMarkdown = commentsToMarkdown(workItem, comments.comments || []);
1027
+ const commentsPath = getCommentsFilePath(syncConfig.folder, workItemId);
1028
+ await writeWorkItemFile(commentsPath, commentsMarkdown);
1029
+ commentsFiles.push({
1030
+ id: workItemId,
1031
+ file: commentsPath,
1032
+ count: (comments.comments || []).length,
1033
+ });
1034
+ filesToCommit.push({ filePath: commentsPath, workItemId });
1035
+ }
1036
+ }
1037
+ catch (error) {
1038
+ skipped.push({ id: workItemId, reason: error.message });
1039
+ }
1040
+ }
1041
+ // Auto-commit if enabled
1042
+ let committed = false;
1043
+ if (syncConfig.autoCommit && filesToCommit.length > 0) {
1044
+ const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced');
1045
+ committed = commitResult.committed;
1046
+ }
1047
+ const result = {
1048
+ pulled,
1049
+ skipped,
1050
+ ...(includeComments ? { commentsFiles } : {}),
1051
+ folder: syncConfig.folder,
1052
+ committed,
1053
+ };
1054
+ return {
1055
+ content: [{
1056
+ type: "text",
1057
+ text: `Synced ${pulled.length} work item(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
1058
+ }],
1059
+ };
1060
+ }
1061
+ catch (error) {
1062
+ console.error("Error syncing work items to files:", error);
1063
+ return {
1064
+ content: [{
1065
+ type: "text",
1066
+ text: `Failed to sync work items: ${error.message}`,
1067
+ }],
1068
+ };
1069
+ }
1070
+ });
1071
+ server.tool("sync-work-item-from-file", "Upload local markdown changes back to ADO. Auto-detects new_*.md files and creates them as new work items. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", {
1072
+ project: z.string().describe("The project name"),
1073
+ workItemIds: z.array(z.number()).optional().describe("Work item IDs to push (optional - new_*.md files are auto-detected)"),
1074
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1075
+ }, async ({ project, workItemIds, folder }) => {
1076
+ try {
1077
+ const service = getAzureDevOpsService();
1078
+ const syncConfig = getSyncConfig(folder);
1079
+ // Validate folder path for security
1080
+ validateFolderPath(syncConfig.folder);
1081
+ const pushed = [];
1082
+ const partial = [];
1083
+ const created = [];
1084
+ const failed = [];
1085
+ // Step 1: Auto-detect and process new_*.md files
1086
+ const newFiles = await findNewWorkItemFiles(syncConfig.folder);
1087
+ for (const filePath of newFiles) {
1088
+ try {
1089
+ // Parse the new work item file
1090
+ const content = await readFileContent(filePath);
1091
+ const parsed = parseNewWorkItemMarkdown(content);
1092
+ // Fetch parent work item to inherit area/iteration paths
1093
+ const parentWorkItem = await service.getWorkItem(project, parsed.frontmatter.parent);
1094
+ // Build fields for creation
1095
+ const fields = buildNewWorkItemFields(parsed, parentWorkItem);
1096
+ // Create the work item in ADO with parent link
1097
+ const createdWorkItem = await service.createWorkItem(project, parsed.frontmatter.type || 'User Story', fields, parsed.frontmatter.parent);
1098
+ const newId = createdWorkItem.id;
1099
+ const revision = createdWorkItem.rev || createdWorkItem._rev || 1;
1100
+ const url = createdWorkItem._links?.html?.href || `https://dev.azure.com/_workitems/edit/${newId}`;
1101
+ // Convert the file content to synced format
1102
+ const syncedContent = convertNewFileToSynced(content, newId, revision, url);
1103
+ // Rename file from new_*.md to {id}.md
1104
+ const newFilePath = getWorkItemFilePath(syncConfig.folder, newId);
1105
+ await writeWorkItemFile(newFilePath, syncedContent);
1106
+ await renameFile(filePath, filePath + '.created'); // Mark original as processed
1107
+ // Actually delete the .created file (cleanup)
1108
+ try {
1109
+ const fs = await import('node:fs/promises');
1110
+ await fs.unlink(filePath + '.created');
1111
+ }
1112
+ catch {
1113
+ // Ignore cleanup errors
1114
+ }
1115
+ created.push({
1116
+ id: newId,
1117
+ oldFile: filePath,
1118
+ newFile: newFilePath,
1119
+ parentId: parsed.frontmatter.parent,
1120
+ });
1121
+ }
1122
+ catch (error) {
1123
+ failed.push({ file: filePath, error: error.message });
1124
+ }
1125
+ }
1126
+ // Step 2: Process existing work items (if workItemIds provided)
1127
+ const idsToProcess = workItemIds || [];
1128
+ for (const workItemId of idsToProcess) {
1129
+ const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
1130
+ try {
1131
+ // Check if file exists
1132
+ if (!await fileExists(filePath)) {
1133
+ failed.push({ id: workItemId, error: `File not found: ${filePath}` });
1134
+ continue;
1135
+ }
1136
+ // Parse local file
1137
+ const parsed = await readWorkItemFile(filePath);
1138
+ const oldRevision = parsed.frontmatter.lastSyncedRevision;
1139
+ // Fetch current work item from ADO to compare
1140
+ const currentWorkItem = await service.getWorkItem(project, workItemId);
1141
+ // Build patch operations
1142
+ const { operations, skippedFields } = buildPatchOperations(parsed, currentWorkItem);
1143
+ if (operations.length === 0 && skippedFields.length === 0) {
1144
+ // No changes
1145
+ pushed.push({
1146
+ id: workItemId,
1147
+ oldRevision,
1148
+ newRevision: currentWorkItem.rev || currentWorkItem._rev || oldRevision,
1149
+ fieldsUpdated: [],
1150
+ });
1151
+ continue;
1152
+ }
1153
+ // Update ADO if there are operations
1154
+ let newRevision = oldRevision;
1155
+ let fieldsUpdated = [];
1156
+ if (operations.length > 0) {
1157
+ const updatedWorkItem = await service.updateWorkItem(project, workItemId, operations);
1158
+ newRevision = updatedWorkItem.rev || updatedWorkItem._rev || oldRevision + 1;
1159
+ fieldsUpdated = operations.map(op => op.path.replace('/fields/', ''));
1160
+ // Update local file with new revision
1161
+ const content = await readFileContent(filePath);
1162
+ const updatedContent = updateSyncRevision(content, newRevision);
1163
+ await writeWorkItemFile(filePath, updatedContent);
1164
+ }
1165
+ if (skippedFields.length > 0) {
1166
+ partial.push({
1167
+ id: workItemId,
1168
+ oldRevision,
1169
+ newRevision,
1170
+ fieldsUpdated,
1171
+ skippedFields,
1172
+ });
1173
+ }
1174
+ else {
1175
+ pushed.push({
1176
+ id: workItemId,
1177
+ oldRevision,
1178
+ newRevision,
1179
+ fieldsUpdated,
1180
+ });
1181
+ }
1182
+ }
1183
+ catch (error) {
1184
+ failed.push({ id: workItemId, error: error.message });
1185
+ }
1186
+ }
1187
+ const result = {
1188
+ created,
1189
+ pushed,
1190
+ partial,
1191
+ failed,
1192
+ folder: syncConfig.folder,
1193
+ };
1194
+ const summary = [];
1195
+ if (created.length > 0)
1196
+ summary.push(`Created ${created.length} new work item(s)`);
1197
+ if (pushed.length > 0)
1198
+ summary.push(`Updated ${pushed.length} work item(s)`);
1199
+ if (partial.length > 0)
1200
+ summary.push(`Partially updated ${partial.length} work item(s)`);
1201
+ if (failed.length > 0)
1202
+ summary.push(`Failed: ${failed.length}`);
1203
+ return {
1204
+ content: [{
1205
+ type: "text",
1206
+ text: `${summary.join(', ')}:\n\n${JSON.stringify(result, null, 2)}`,
1207
+ }],
1208
+ };
1209
+ }
1210
+ catch (error) {
1211
+ console.error("Error syncing work items from files:", error);
1212
+ return {
1213
+ content: [{
1214
+ type: "text",
1215
+ text: `Failed to push work items: ${error.message}`,
1216
+ }],
1217
+ };
1218
+ }
1219
+ });
1220
+ server.tool("check-work-item-markdown", "Check if work item fields are markdown (required for sync) or HTML format.", {
1221
+ project: z.string().describe("The project name"),
1222
+ workItemIds: z.array(z.number()).describe("Work item IDs to check"),
1223
+ }, async ({ project, workItemIds }) => {
1224
+ try {
1225
+ const service = getAzureDevOpsService();
1226
+ const results = [];
1227
+ let readyCount = 0;
1228
+ let needsConversionCount = 0;
1229
+ for (const workItemId of workItemIds) {
1230
+ try {
1231
+ const workItem = await service.getWorkItem(project, workItemId);
1232
+ const formats = checkFieldFormats(workItem);
1233
+ results.push({
1234
+ id: workItemId,
1235
+ description: formats.description,
1236
+ acceptanceCriteria: formats.acceptanceCriteria,
1237
+ howToTest: formats.additionalFields.howToTest,
1238
+ predeploymentSteps: formats.additionalFields.predeploymentSteps,
1239
+ postdeploymentSteps: formats.additionalFields.postdeploymentSteps,
1240
+ deploymentInformation: formats.additionalFields.deploymentInformation,
1241
+ ready: formats.ready,
1242
+ ...(formats.warnings.length > 0 ? { warnings: formats.warnings } : {}),
1243
+ });
1244
+ if (formats.ready) {
1245
+ readyCount++;
1246
+ }
1247
+ else {
1248
+ needsConversionCount++;
1249
+ }
1250
+ }
1251
+ catch (error) {
1252
+ results.push({
1253
+ id: workItemId,
1254
+ description: 'error',
1255
+ acceptanceCriteria: 'error',
1256
+ ready: false,
1257
+ error: error.message || String(error),
1258
+ });
1259
+ needsConversionCount++;
1260
+ }
1261
+ }
1262
+ const result = {
1263
+ results,
1264
+ summary: {
1265
+ ready: readyCount,
1266
+ needsConversion: needsConversionCount,
1267
+ },
1268
+ conversionInstructions: needsConversionCount > 0 ? getConversionInstructions() : undefined,
1269
+ };
1270
+ return {
1271
+ content: [{
1272
+ type: "text",
1273
+ text: `Work item format check:\n\n${JSON.stringify(result, null, 2)}`,
1274
+ }],
1275
+ };
1276
+ }
1277
+ catch (error) {
1278
+ console.error("Error checking work item formats:", error);
1279
+ return {
1280
+ content: [{
1281
+ type: "text",
1282
+ text: `Failed to check work item formats: ${error.message}`,
1283
+ }],
1284
+ };
1285
+ }
1286
+ });
1287
+ server.tool("list-synced-work-items", "List work items that have been synced to local markdown files.", {
1288
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1289
+ }, async ({ folder }) => {
1290
+ try {
1291
+ const syncConfig = getSyncConfig(folder);
1292
+ // Validate folder path for security
1293
+ validateFolderPath(syncConfig.folder);
1294
+ const workItems = await listSyncedWorkItems(syncConfig.folder);
1295
+ const result = {
1296
+ workItems,
1297
+ folder: syncConfig.folder,
1298
+ count: workItems.length,
1299
+ };
1300
+ return {
1301
+ content: [{
1302
+ type: "text",
1303
+ text: `Synced work items in ${syncConfig.folder}:\n\n${JSON.stringify(result, null, 2)}`,
1304
+ }],
1305
+ };
1306
+ }
1307
+ catch (error) {
1308
+ console.error("Error listing synced work items:", error);
1309
+ return {
1310
+ content: [{
1311
+ type: "text",
1312
+ text: `Failed to list synced work items: ${error.message}`,
1313
+ }],
1314
+ };
1315
+ }
1316
+ });
1317
+ 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.", {
1318
+ project: z.string().describe("The project name"),
1319
+ parentId: z.number().describe("Parent Feature ID - the new user story will be created under this feature"),
1320
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1321
+ }, async ({ project, parentId, folder }) => {
1322
+ try {
1323
+ const service = getAzureDevOpsService();
1324
+ const syncConfig = getSyncConfig(folder);
1325
+ // Validate folder path for security
1326
+ validateFolderPath(syncConfig.folder);
1327
+ // Fetch parent work item to get title
1328
+ const parentWorkItem = await service.getWorkItem(project, parentId);
1329
+ const parentTitle = parentWorkItem.fields?.['System.Title'] || '';
1330
+ // Find next available index for new file
1331
+ const nextIndex = await findNextNewFileIndex(syncConfig.folder, parentId);
1332
+ const filePath = getNewWorkItemFilePath(syncConfig.folder, parentId, nextIndex);
1333
+ // Generate template content
1334
+ const template = generateNewWorkItemTemplate(parentId, parentTitle, project, 'User Story');
1335
+ // Ensure folder exists and write file
1336
+ await ensureFolderExists(syncConfig.folder);
1337
+ await writeWorkItemFile(filePath, template);
1338
+ const result = {
1339
+ file: filePath,
1340
+ parentId,
1341
+ parentTitle,
1342
+ instructions: [
1343
+ `1. Edit the file to update title, description, and acceptance criteria`,
1344
+ `2. Run sync-work-item-from-file(project: "${project}") to create in ADO`,
1345
+ `3. The file will be renamed to {newId}.md after creation`,
1346
+ ],
1347
+ };
1348
+ return {
1349
+ content: [{
1350
+ type: "text",
1351
+ text: `Created new user story template:\n\n${JSON.stringify(result, null, 2)}`,
1352
+ }],
1353
+ };
1354
+ }
1355
+ catch (error) {
1356
+ console.error("Error creating user story file:", error);
1357
+ return {
1358
+ content: [{
1359
+ type: "text",
1360
+ text: `Failed to create user story file: ${error.message}`,
1361
+ }],
1362
+ };
1363
+ }
1364
+ });
1365
+ // ========================================
1366
+ // TASK SYNC TOOLS
1367
+ // ========================================
1368
+ server.tool("sync-tasks-to-file", "Download all tasks under a parent work item (User Story) to a local markdown file. Supports pulling tasks for multiple parents at once.", {
1369
+ project: z.string().describe("The project name"),
1370
+ parentIds: z.array(z.number()).describe("Parent work item IDs (User Stories) to fetch tasks for"),
1371
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1372
+ }, async ({ project, parentIds, folder }) => {
1373
+ try {
1374
+ const service = getAzureDevOpsService();
1375
+ const syncConfig = getSyncConfig(folder);
1376
+ // Validate folder path for security
1377
+ validateFolderPath(syncConfig.folder);
1378
+ const pulled = [];
1379
+ const failed = [];
1380
+ const filesToCommit = [];
1381
+ await ensureFolderExists(syncConfig.folder);
1382
+ for (const parentId of parentIds) {
1383
+ try {
1384
+ // Fetch parent work item to get title and validate existence
1385
+ const parentWorkItem = await service.getWorkItem(project, parentId);
1386
+ // Query for child tasks using WIQL
1387
+ const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = 'Task' ORDER BY [System.Id] ASC`;
1388
+ const queryResult = await service.queryWorkItems(project, wiql, 200);
1389
+ // Fetch full details for each task
1390
+ const tasks = [];
1391
+ if (queryResult.workItems && queryResult.workItems.length > 0) {
1392
+ for (const wi of queryResult.workItems) {
1393
+ try {
1394
+ const task = await service.getWorkItem(project, wi.id);
1395
+ tasks.push(task);
1396
+ }
1397
+ catch (taskError) {
1398
+ console.error(`Error fetching task ${wi.id}:`, taskError.message);
1399
+ }
1400
+ }
1401
+ }
1402
+ // Convert to markdown and save
1403
+ const markdown = tasksToMarkdown(parentWorkItem, tasks, project);
1404
+ const filePath = getTasksFilePath(syncConfig.folder, parentId);
1405
+ await writeWorkItemFile(filePath, markdown);
1406
+ pulled.push({
1407
+ parentId,
1408
+ file: filePath,
1409
+ taskCount: tasks.length,
1410
+ });
1411
+ filesToCommit.push({ filePath, workItemId: parentId });
1412
+ }
1413
+ catch (error) {
1414
+ failed.push({ parentId, error: error.message });
1415
+ }
1416
+ }
1417
+ // Auto-commit if enabled
1418
+ let committed = false;
1419
+ if (syncConfig.autoCommit && filesToCommit.length > 0) {
1420
+ const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced tasks for');
1421
+ committed = commitResult.committed;
1422
+ }
1423
+ const result = {
1424
+ pulled,
1425
+ failed,
1426
+ folder: syncConfig.folder,
1427
+ committed,
1428
+ };
1429
+ return {
1430
+ content: [{
1431
+ type: "text",
1432
+ text: `Synced tasks for ${pulled.length} parent(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
1433
+ }],
1434
+ };
1435
+ }
1436
+ catch (error) {
1437
+ console.error("Error syncing tasks to files:", error);
1438
+ return {
1439
+ content: [{
1440
+ type: "text",
1441
+ text: `Failed to sync tasks: ${error.message}`,
1442
+ }],
1443
+ };
1444
+ }
1445
+ });
1446
+ server.tool("sync-tasks-from-file", "Push local task changes back to ADO with upsert semantics. Existing tasks (## Task #ID) are updated, new tasks (## NEW TASK) are created. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", {
1447
+ project: z.string().describe("The project name"),
1448
+ parentIds: z.array(z.number()).describe("Parent work item IDs to sync tasks for"),
1449
+ folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1450
+ }, async ({ project, parentIds, folder }) => {
1451
+ try {
1452
+ const service = getAzureDevOpsService();
1453
+ const syncConfig = getSyncConfig(folder);
1454
+ // Validate folder path for security
1455
+ validateFolderPath(syncConfig.folder);
1456
+ const updated = [];
1457
+ const created = [];
1458
+ const failed = [];
1459
+ for (const parentId of parentIds) {
1460
+ const filePath = getTasksFilePath(syncConfig.folder, parentId);
1461
+ try {
1462
+ // Check if file exists
1463
+ if (!await fileExists(filePath)) {
1464
+ failed.push({ parentId, error: `File not found: ${filePath}` });
1465
+ continue;
1466
+ }
1467
+ // Parse local file
1468
+ const content = await readFileContent(filePath);
1469
+ const parsed = parseTasksMarkdown(content);
1470
+ // Get parent work item for area/iteration path
1471
+ const parentWorkItem = await service.getWorkItem(project, parentId);
1472
+ const parentFields = parentWorkItem.fields || {};
1473
+ const areaPath = parentFields['System.AreaPath'];
1474
+ const iterationPath = parentFields['System.IterationPath'];
1475
+ const createdInThisFile = [];
1476
+ // Process each task
1477
+ for (const task of parsed.tasks) {
1478
+ try {
1479
+ if (task.id !== null) {
1480
+ // Existing task - update
1481
+ const currentTask = await service.getWorkItem(project, task.id);
1482
+ const { operations, fieldsUpdated } = buildTaskPatchOperations(task, currentTask);
1483
+ if (operations.length > 0) {
1484
+ await service.updateWorkItem(project, task.id, operations);
1485
+ updated.push({
1486
+ id: task.id,
1487
+ parentId,
1488
+ fieldsUpdated,
1489
+ });
1490
+ }
1491
+ else {
1492
+ // No changes needed
1493
+ updated.push({
1494
+ id: task.id,
1495
+ parentId,
1496
+ fieldsUpdated: [],
1497
+ });
1498
+ }
1499
+ }
1500
+ else {
1501
+ // New task - create
1502
+ if (!task.title) {
1503
+ continue; // Skip tasks without title
1504
+ }
1505
+ const fields = buildNewTaskFields(task, areaPath, iterationPath);
1506
+ const createdTask = await service.createWorkItem(project, 'Task', fields, parentId);
1507
+ created.push({
1508
+ id: createdTask.id,
1509
+ parentId,
1510
+ title: task.title,
1511
+ });
1512
+ createdInThisFile.push({
1513
+ title: task.title,
1514
+ id: createdTask.id,
1515
+ });
1516
+ }
1517
+ }
1518
+ catch (taskError) {
1519
+ failed.push({
1520
+ parentId,
1521
+ taskId: task.id || undefined,
1522
+ error: taskError.message,
1523
+ });
1524
+ }
1525
+ }
1526
+ // Update the file with new task IDs if any were created
1527
+ if (createdInThisFile.length > 0) {
1528
+ const updatedContent = updateTasksFileAfterCreate(content, createdInThisFile);
1529
+ await writeWorkItemFile(filePath, updatedContent);
1530
+ }
1531
+ }
1532
+ catch (error) {
1533
+ failed.push({ parentId, error: error.message });
1534
+ }
1535
+ }
1536
+ const result = {
1537
+ updated,
1538
+ created,
1539
+ failed,
1540
+ folder: syncConfig.folder,
1541
+ };
1542
+ return {
1543
+ content: [{
1544
+ type: "text",
1545
+ text: `Pushed tasks to ADO (${updated.length} updated, ${created.length} created):\n\n${JSON.stringify(result, null, 2)}`,
1546
+ }],
1547
+ };
1548
+ }
1549
+ catch (error) {
1550
+ console.error("Error syncing tasks from files:", error);
1551
+ return {
1552
+ content: [{
1553
+ type: "text",
1554
+ text: `Failed to push tasks: ${error.message}`,
1555
+ }],
1556
+ };
1557
+ }
1558
+ });
903
1559
  // Log registration summary (enablePullRequestWrite already defined above)
904
- const baseToolsCount = 21; // 15 original + 6 PR read-only tools
1560
+ const syncToolsCount = 4; // Work item sync tools
1561
+ const taskSyncToolsCount = 2; // Task sync tools
1562
+ const baseToolsCount = 21 + syncToolsCount + taskSyncToolsCount; // 15 original + 6 PR read-only + 4 work item sync + 2 task sync
905
1563
  const prWriteToolsCount = enablePullRequestWrite ? 1 : 0;
906
1564
  const totalToolsCount = baseToolsCount + prWriteToolsCount;
907
1565
  console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${prWriteToolsCount} PR write), 4 prompts`);