@azure-devops/mcp 2.7.0-nightly.20260518 → 2.7.0-nightly.20260520

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.
@@ -984,11 +984,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
984
984
  originalPath: origPath,
985
985
  };
986
986
  });
987
- if (fileDiffParams.length > 0) {
988
- try {
987
+ try {
988
+ // Fetch diffs for modified files. Add/Delete files are excluded from getFileDiffs
989
+ // because they don't have two versions to compare; their content is fetched
990
+ // separately below via getItemText when includeLineContent is true.
991
+ let fileDiffs = [];
992
+ if (fileDiffParams.length > 0) {
989
993
  // Azure DevOps getFileDiffs API accepts max 10 files per request
990
994
  const FILE_DIFF_BATCH_SIZE = 10;
991
- let fileDiffs = [];
992
995
  for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) {
993
996
  const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE);
994
997
  const batchDiffs = await gitApi.getFileDiffs({
@@ -998,207 +1001,215 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
998
1001
  }, project || "", repositoryId);
999
1002
  fileDiffs = fileDiffs.concat(batchDiffs);
1000
1003
  }
1001
- // Merge diff content with change metadata
1002
- const enrichedChanges = {
1003
- ...changes,
1004
- changeEntries: changes.changeEntries.map((entry) => {
1005
- // Normalize path for comparison (remove leading slash)
1006
- const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1007
- const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
1008
- return {
1009
- ...entry,
1010
- diff: matchingDiff || null,
1011
- };
1012
- }),
1013
- };
1014
- // If includeLineContent is true, fetch actual file content with concurrency limit
1015
- if (includeLineContent && enrichedChanges.changeEntries) {
1016
- const CONCURRENCY_LIMIT = 10;
1017
- const entriesWithContent = [...enrichedChanges.changeEntries];
1018
- for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
1019
- const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
1020
- const batchResults = await Promise.all(batch.map(async (entry) => {
1021
- const ct = entry.changeType ?? 0;
1022
- const isAdd = !!(ct & VersionControlChangeType.Add);
1023
- const isDelete = !!(ct & VersionControlChangeType.Delete);
1024
- const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1025
- if (!entryPath) {
1026
- return entry;
1027
- }
1028
- // Handle added files: fetch full content at target commit and create synthetic diff
1029
- if (isAdd && !entry.diff) {
1030
- try {
1031
- const targetStream = await gitApi
1032
- .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1033
- .catch(() => null);
1034
- if (targetStream) {
1035
- const targetText = await streamToString(targetStream);
1036
- const targetLines = targetText.split(/\r?\n/);
1037
- return {
1038
- ...entry,
1039
- diff: {
1040
- path: entryPath,
1041
- originalPath: entryPath,
1042
- lineDiffBlocks: [
1043
- {
1044
- changeType: 1, // Add
1045
- originalLineNumberStart: 0,
1046
- originalLinesCount: 0,
1047
- modifiedLineNumberStart: 1,
1048
- modifiedLinesCount: targetLines.length,
1049
- modifiedLines: targetLines,
1050
- },
1051
- ],
1052
- },
1053
- };
1054
- }
1055
- }
1056
- catch (addError) {
1004
+ }
1005
+ // Merge diff content with change metadata.
1006
+ // Added/deleted entries get diff: null here and are enriched below.
1007
+ const enrichedChanges = {
1008
+ ...changes,
1009
+ changeEntries: changes.changeEntries.map((entry) => {
1010
+ // Normalize path for comparison (remove leading slash)
1011
+ const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1012
+ const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
1013
+ return {
1014
+ ...entry,
1015
+ diff: matchingDiff || null,
1016
+ };
1017
+ }),
1018
+ };
1019
+ // If includeLineContent is true, fetch actual file content with concurrency limit
1020
+ if (includeLineContent && enrichedChanges.changeEntries) {
1021
+ const CONCURRENCY_LIMIT = 10;
1022
+ const entriesWithContent = [...enrichedChanges.changeEntries];
1023
+ for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
1024
+ const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
1025
+ const batchResults = await Promise.all(batch.map(async (entry) => {
1026
+ const ct = entry.changeType ?? 0;
1027
+ const isAdd = !!(ct & VersionControlChangeType.Add);
1028
+ const isDelete = !!(ct & VersionControlChangeType.Delete);
1029
+ const entryPath = entry.item?.path ? (entry.item.path.startsWith("/") ? entry.item.path.substring(1) : entry.item.path) : undefined;
1030
+ // For deleted files ADO sets item.path to null and puts the path in originalPath only.
1031
+ // Normalise originalPath once and use it as the fallback throughout.
1032
+ const normalizedOriginalPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : undefined;
1033
+ // effectivePath is what we use as the "current" path for API calls / early-exit guard.
1034
+ // For additions/modifications it's item.path; for deletions it's originalPath.
1035
+ const effectivePath = entryPath ?? normalizedOriginalPath;
1036
+ if (!effectivePath) {
1037
+ return entry;
1038
+ }
1039
+ // Handle added files: fetch full content at target commit and create synthetic diff
1040
+ if (isAdd && !entry.diff) {
1041
+ try {
1042
+ const targetStream = await gitApi
1043
+ .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1044
+ .catch(() => null);
1045
+ if (targetStream) {
1046
+ const targetText = await streamToString(targetStream);
1047
+ const targetLines = targetText.split(/\r?\n/);
1057
1048
  return {
1058
1049
  ...entry,
1059
- _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1050
+ diff: {
1051
+ path: effectivePath,
1052
+ originalPath: null,
1053
+ lineDiffBlocks: [
1054
+ {
1055
+ changeType: 1, // Add
1056
+ originalLineNumberStart: 0,
1057
+ originalLinesCount: 0,
1058
+ modifiedLineNumberStart: 1,
1059
+ modifiedLinesCount: targetLines.length,
1060
+ modifiedLines: targetLines,
1061
+ },
1062
+ ],
1063
+ },
1060
1064
  };
1061
1065
  }
1062
- return entry;
1063
1066
  }
1064
- // Handle deleted files: fetch full content at base commit and create synthetic diff
1065
- if (isDelete && !entry.diff) {
1066
- try {
1067
- const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1068
- const baseStream = await gitApi
1069
- .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1070
- .catch(() => null);
1071
- if (baseStream) {
1072
- const baseText = await streamToString(baseStream);
1073
- const baseLines = baseText.split(/\r?\n/);
1074
- return {
1075
- ...entry,
1076
- diff: {
1077
- path: entryPath,
1078
- originalPath: basePath,
1079
- lineDiffBlocks: [
1080
- {
1081
- changeType: 2, // Delete
1082
- originalLineNumberStart: 1,
1083
- originalLinesCount: baseLines.length,
1084
- modifiedLineNumberStart: 0,
1085
- modifiedLinesCount: 0,
1086
- originalLines: baseLines,
1087
- },
1088
- ],
1089
- },
1090
- };
1091
- }
1092
- }
1093
- catch (delError) {
1067
+ catch (addError) {
1068
+ return {
1069
+ ...entry,
1070
+ _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1071
+ };
1072
+ }
1073
+ return entry;
1074
+ }
1075
+ // Handle deleted files: fetch full content at base commit and create synthetic diff.
1076
+ // basePath prefers originalPath (the pre-deletion path); falls back to effectivePath.
1077
+ if (isDelete && !entry.diff) {
1078
+ try {
1079
+ const basePath = normalizedOriginalPath ?? effectivePath;
1080
+ const baseStream = await gitApi
1081
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1082
+ .catch(() => null);
1083
+ if (baseStream) {
1084
+ const baseText = await streamToString(baseStream);
1085
+ const baseLines = baseText.split(/\r?\n/);
1094
1086
  return {
1095
1087
  ...entry,
1096
- _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1088
+ diff: {
1089
+ path: null,
1090
+ originalPath: basePath,
1091
+ lineDiffBlocks: [
1092
+ {
1093
+ changeType: 2, // Delete
1094
+ originalLineNumberStart: 1,
1095
+ originalLinesCount: baseLines.length,
1096
+ modifiedLineNumberStart: 0,
1097
+ modifiedLinesCount: 0,
1098
+ originalLines: baseLines,
1099
+ },
1100
+ ],
1101
+ },
1097
1102
  };
1098
1103
  }
1099
- return entry;
1100
- }
1101
- // For modified/renamed files, skip if no diff blocks
1102
- if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1103
- return entry;
1104
- }
1105
- // For renamed/moved files, the base version is at the original path
1106
- const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1107
- try {
1108
- // Fetch file content at both commits
1109
- const [baseContent, targetContent] = await Promise.all([
1110
- // Base version (original) - use basePath for renamed files
1111
- gitApi
1112
- .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1113
- .catch(() => null),
1114
- // Target version (modified)
1115
- gitApi
1116
- .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1117
- .catch(() => null),
1118
- ]);
1119
- // Convert streams to text
1120
- const baseText = baseContent ? await streamToString(baseContent) : "";
1121
- const targetText = targetContent ? await streamToString(targetContent) : "";
1122
- // Check if response is an Azure DevOps error (returned as JSON in the stream)
1123
- const checkForApiError = (text, label) => {
1124
- if (text.startsWith("{")) {
1125
- try {
1126
- const parsed = JSON.parse(text);
1127
- if (parsed.$id && parsed.innerException !== undefined) {
1128
- throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1129
- }
1130
- }
1131
- catch (e) {
1132
- if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1133
- throw e;
1134
- // Not valid JSON or not an error response — treat as legitimate content
1135
- }
1136
- }
1137
- };
1138
- checkForApiError(baseText, "base");
1139
- checkForApiError(targetText, "target");
1140
- // Split into lines
1141
- const baseLines = baseText.split(/\r?\n/);
1142
- const targetLines = targetText.split(/\r?\n/);
1143
- // Enrich each lineDiffBlock with actual line content
1144
- const enrichedDiff = {
1145
- ...entry.diff,
1146
- lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1147
- const enrichedBlock = { ...block };
1148
- // Add original (base) lines if they exist
1149
- if (block.originalLineNumberStart && block.originalLinesCount) {
1150
- const startIdx = block.originalLineNumberStart - 1;
1151
- const endIdx = startIdx + block.originalLinesCount;
1152
- enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1153
- }
1154
- // Add modified (target) lines if they exist
1155
- if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1156
- const startIdx = block.modifiedLineNumberStart - 1;
1157
- const endIdx = startIdx + block.modifiedLinesCount;
1158
- enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1159
- }
1160
- return enrichedBlock;
1161
- }),
1162
- };
1163
- return {
1164
- ...entry,
1165
- diff: enrichedDiff,
1166
- };
1167
1104
  }
1168
- catch (contentError) {
1169
- // If content fetch fails, return entry with error
1105
+ catch (delError) {
1170
1106
  return {
1171
1107
  ...entry,
1172
- _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1108
+ _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1173
1109
  };
1174
1110
  }
1175
- }));
1176
- // Write batch results back into the array
1177
- for (let j = 0; j < batchResults.length; j++) {
1178
- entriesWithContent[i + j] = batchResults[j];
1111
+ return entry;
1112
+ }
1113
+ // For modified/renamed files, skip if no diff blocks
1114
+ if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1115
+ return entry;
1116
+ }
1117
+ // For renamed/moved files, the base version is at the original path
1118
+ const basePath = normalizedOriginalPath ?? effectivePath;
1119
+ try {
1120
+ // Fetch file content at both commits
1121
+ const [baseContent, targetContent] = await Promise.all([
1122
+ // Base version (original) - use basePath for renamed files
1123
+ gitApi
1124
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1125
+ .catch(() => null),
1126
+ // Target version (modified)
1127
+ gitApi
1128
+ .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1129
+ .catch(() => null),
1130
+ ]);
1131
+ // Convert streams to text
1132
+ const baseText = baseContent ? await streamToString(baseContent) : "";
1133
+ const targetText = targetContent ? await streamToString(targetContent) : "";
1134
+ // Check if response is an Azure DevOps error (returned as JSON in the stream)
1135
+ const checkForApiError = (text, label) => {
1136
+ if (text.startsWith("{")) {
1137
+ try {
1138
+ const parsed = JSON.parse(text);
1139
+ if (parsed.$id && parsed.innerException !== undefined) {
1140
+ throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1141
+ }
1142
+ }
1143
+ catch (e) {
1144
+ if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1145
+ throw e;
1146
+ // Not valid JSON or not an error response — treat as legitimate content
1147
+ }
1148
+ }
1149
+ };
1150
+ checkForApiError(baseText, "base");
1151
+ checkForApiError(targetText, "target");
1152
+ // Split into lines
1153
+ const baseLines = baseText.split(/\r?\n/);
1154
+ const targetLines = targetText.split(/\r?\n/);
1155
+ // Enrich each lineDiffBlock with actual line content
1156
+ const enrichedDiff = {
1157
+ ...entry.diff,
1158
+ lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1159
+ const enrichedBlock = { ...block };
1160
+ // Add original (base) lines if they exist
1161
+ if (block.originalLineNumberStart && block.originalLinesCount) {
1162
+ const startIdx = block.originalLineNumberStart - 1;
1163
+ const endIdx = startIdx + block.originalLinesCount;
1164
+ enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1165
+ }
1166
+ // Add modified (target) lines if they exist
1167
+ if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1168
+ const startIdx = block.modifiedLineNumberStart - 1;
1169
+ const endIdx = startIdx + block.modifiedLinesCount;
1170
+ enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1171
+ }
1172
+ return enrichedBlock;
1173
+ }),
1174
+ };
1175
+ return {
1176
+ ...entry,
1177
+ diff: enrichedDiff,
1178
+ };
1179
+ }
1180
+ catch (contentError) {
1181
+ // If content fetch fails, return entry with error
1182
+ return {
1183
+ ...entry,
1184
+ _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1185
+ };
1179
1186
  }
1187
+ }));
1188
+ // Write batch results back into the array
1189
+ for (let j = 0; j < batchResults.length; j++) {
1190
+ entriesWithContent[i + j] = batchResults[j];
1180
1191
  }
1181
- enrichedChanges.changeEntries = entriesWithContent;
1182
1192
  }
1183
- return {
1184
- content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1185
- };
1186
- }
1187
- catch (diffError) {
1188
- // If diff fetching fails, return metadata with error info
1189
- return {
1190
- content: [
1191
- {
1192
- type: "text",
1193
- text: JSON.stringify({
1194
- ...changes,
1195
- _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1196
- _note: "Returned metadata only",
1197
- }, null, 2),
1198
- },
1199
- ],
1200
- };
1193
+ enrichedChanges.changeEntries = entriesWithContent;
1201
1194
  }
1195
+ return {
1196
+ content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1197
+ };
1198
+ }
1199
+ catch (diffError) {
1200
+ // If diff fetching fails, return metadata with error info
1201
+ return {
1202
+ content: [
1203
+ {
1204
+ type: "text",
1205
+ text: JSON.stringify({
1206
+ ...changes,
1207
+ _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1208
+ _note: "Returned metadata only",
1209
+ }, null, 2),
1210
+ },
1211
+ ],
1212
+ };
1202
1213
  }
1203
1214
  }
1204
1215
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.7.0-nightly.20260518";
1
+ export const packageVersion = "2.7.0-nightly.20260520";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "2.7.0-nightly.20260518",
3
+ "version": "2.7.0-nightly.20260520",
4
4
  "mcpName": "microsoft.com/azure-devops",
5
5
  "description": "MCP server for interacting with Azure DevOps",
6
6
  "license": "MIT",