@azure-devops/mcp 2.7.0-nightly.20260427 → 2.7.0-nightly.20260428

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.
@@ -3,7 +3,7 @@
3
3
  import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
4
4
  import { z } from "zod";
5
5
  import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
6
- import { getEnumKeys, streamToString } from "../utils.js";
6
+ import { extractAdoStreamError, getEnumKeys, streamToString } from "../utils.js";
7
7
  const REPO_TOOLS = {
8
8
  list_repos_by_project: "repo_list_repos_by_project",
9
9
  list_pull_requests_by_repo_or_project: "repo_list_pull_requests_by_repo_or_project",
@@ -618,33 +618,33 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
618
618
  try {
619
619
  const connection = await connectionProvider();
620
620
  const gitApi = await connection.getGitApi();
621
- const threads = await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration);
621
+ const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? [];
622
622
  let filteredThreads = threads;
623
623
  if (status !== undefined) {
624
624
  const statusValue = CommentThreadStatus[status];
625
- filteredThreads = filteredThreads?.filter((thread) => thread.status === statusValue);
625
+ filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue);
626
626
  }
627
627
  if (authorEmail !== undefined) {
628
- filteredThreads = filteredThreads?.filter((thread) => {
628
+ filteredThreads = filteredThreads.filter((thread) => {
629
629
  const firstComment = thread.comments?.[0];
630
630
  return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase();
631
631
  });
632
632
  }
633
633
  if (authorDisplayName !== undefined) {
634
634
  const lowerAuthorName = authorDisplayName.toLowerCase();
635
- filteredThreads = filteredThreads?.filter((thread) => {
635
+ filteredThreads = filteredThreads.filter((thread) => {
636
636
  const firstComment = thread.comments?.[0];
637
637
  return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName);
638
638
  });
639
639
  }
640
- const paginatedThreads = filteredThreads?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
640
+ const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
641
641
  if (fullResponse) {
642
642
  return {
643
643
  content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }],
644
644
  };
645
645
  }
646
646
  // Return trimmed thread data focusing on essential information
647
- const trimmedThreads = paginatedThreads?.map((thread) => trimPullRequestThread(thread));
647
+ const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread));
648
648
  return {
649
649
  content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }],
650
650
  };
@@ -1710,6 +1710,13 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1710
1710
  versionDescriptor, true // includeContent
1711
1711
  );
1712
1712
  const content = await streamToString(stream);
1713
+ const streamError = extractAdoStreamError(content);
1714
+ if (streamError) {
1715
+ return {
1716
+ content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }],
1717
+ isError: true,
1718
+ };
1719
+ }
1713
1720
  return {
1714
1721
  content: [{ type: "text", text: content }],
1715
1722
  };
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
3
  import { z } from "zod";
4
- import { apiVersion } from "../utils.js";
4
+ import { apiVersion, extractAdoStreamError } from "../utils.js";
5
5
  import { createExternalContentResponse } from "../shared/content-safety.js";
6
6
  const WIKI_TOOLS = {
7
7
  list_wikis: "wiki_list_wikis",
@@ -209,6 +209,13 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
209
209
  return { content: [{ type: "text", text: "No wiki page content found" }], isError: true };
210
210
  }
211
211
  pageContent = await streamToString(stream);
212
+ const streamError = extractAdoStreamError(pageContent);
213
+ if (streamError) {
214
+ return {
215
+ content: [{ type: "text", text: `Error fetching wiki page content: ${streamError}` }],
216
+ isError: true,
217
+ };
218
+ }
212
219
  }
213
220
  return createExternalContentResponse(pageContent, "wiki page");
214
221
  }
@@ -1,5 +1,7 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
+ import * as fs from "fs";
4
+ import * as path from "path";
3
5
  import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
4
6
  import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
5
7
  import { z } from "zod";
@@ -407,8 +409,9 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
407
409
  if (revisions && Array.isArray(revisions)) {
408
410
  revisions.forEach((revision) => {
409
411
  if (revision.fields) {
410
- Object.keys(revision.fields).forEach((fieldName) => {
411
- const fieldValue = revision.fields ? revision.fields[fieldName] : undefined;
412
+ const fields = revision.fields;
413
+ Object.keys(fields).forEach((fieldName) => {
414
+ const fieldValue = fields[fieldName];
412
415
  // Check if this is an identity object by looking for common identity properties
413
416
  if (fieldValue &&
414
417
  typeof fieldValue === "object" &&
@@ -1210,11 +1213,23 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
1210
1213
  };
1211
1214
  }
1212
1215
  });
1213
- server.tool(WORKITEM_TOOLS.get_work_item_attachment, "Download a work item attachment by its ID and return the content as a base64-encoded resource. Useful for viewing images (e.g. screenshots) attached to work items such as bugs. If a project is not specified, you will be prompted to select one.", {
1216
+ server.tool(WORKITEM_TOOLS.get_work_item_attachment, "Download a work item attachment by its ID. By default returns the content as a base64-encoded resource. If savePath is provided, saves the file locally to that directory and returns the file path instead. Useful for viewing images (e.g. screenshots) or other files attached to work items such as bugs. If a project is not specified, you will be prompted to select one.", {
1214
1217
  project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."),
1215
1218
  attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}"),
1216
- fileName: z.string().optional().describe("The file name of the attachment, e.g. 'screenshot.png'. Used to determine the MIME type for the returned resource."),
1217
- }, async ({ project, attachmentId, fileName }) => {
1219
+ fileName: z.string().optional().describe("The file name of the attachment, e.g. 'screenshot.png'. Used to determine the MIME type or the saved file's name."),
1220
+ savePath: z
1221
+ .string()
1222
+ .optional()
1223
+ .describe("Optional local directory path where the file should be saved. Must be a relative path (e.g. 'temp' or 'downloads/attachments'); absolute paths and path traversals are not allowed. If provided, saves the attachment to this directory and returns the file path. If omitted, returns the content as a base64-encoded resource."),
1224
+ }, async ({ project, attachmentId, fileName, savePath }) => {
1225
+ const isAbsolutePath = (value) => path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
1226
+ const hasDriveLetter = (value) => /^[a-zA-Z]:/.test(value);
1227
+ if (savePath !== undefined && (savePath.includes("..") || isAbsolutePath(savePath) || hasDriveLetter(savePath))) {
1228
+ throw new Error("Invalid savePath: absolute paths and path traversals are not allowed.");
1229
+ }
1230
+ if (fileName !== undefined && fileName.includes("..")) {
1231
+ throw new Error("Invalid fileName: path traversal is not allowed.");
1232
+ }
1218
1233
  try {
1219
1234
  const connection = await connectionProvider();
1220
1235
  let resolvedProject = project;
@@ -1233,8 +1248,24 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
1233
1248
  stream.on("error", reject);
1234
1249
  });
1235
1250
  const buffer = Buffer.concat(chunks);
1236
- const base64Data = buffer.toString("base64");
1251
+ if (savePath) {
1252
+ const resolvedFileName = fileName ?? attachmentId;
1253
+ const localFilePath = path.join(savePath, resolvedFileName);
1254
+ if (fs.existsSync(localFilePath)) {
1255
+ throw new Error(`File already exists: ${localFilePath}`);
1256
+ }
1257
+ fs.writeFileSync(localFilePath, buffer);
1258
+ return {
1259
+ content: [{ type: "text", text: `Attachment saved to: ${localFilePath}` }],
1260
+ };
1261
+ }
1237
1262
  const mimeType = getMimeType(fileName);
1263
+ if (mimeType.startsWith("text/")) {
1264
+ return {
1265
+ content: [{ type: "text", text: buffer.toString("utf-8") }],
1266
+ };
1267
+ }
1268
+ const base64Data = buffer.toString("base64");
1238
1269
  return {
1239
1270
  content: [
1240
1271
  {
@@ -1269,6 +1300,15 @@ function getMimeType(fileName) {
1269
1300
  webp: "image/webp",
1270
1301
  pdf: "application/pdf",
1271
1302
  txt: "text/plain",
1303
+ md: "text/markdown",
1304
+ markdown: "text/markdown",
1305
+ csv: "text/csv",
1306
+ html: "text/html",
1307
+ htm: "text/html",
1308
+ xml: "text/xml",
1309
+ json: "application/json",
1310
+ yaml: "text/yaml",
1311
+ yml: "text/yaml",
1272
1312
  zip: "application/zip",
1273
1313
  };
1274
1314
  return (ext && mimeTypes[ext]) ?? "application/octet-stream";
package/dist/utils.js CHANGED
@@ -67,6 +67,29 @@ export function encodeFormattedValue(value, format) {
67
67
  const result = value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
68
68
  return result;
69
69
  }
70
+ /**
71
+ * Detects whether a string returned from an ADO API stream is actually an error
72
+ * response serialized as JSON (e.g. a 404 GitItemNotFoundException or
73
+ * WikiPageNotFoundException) rather than real content.
74
+ *
75
+ * The ADO Node API client swallows non-2xx HTTP responses and delivers the
76
+ * error body as a stream, so callers must check explicitly after reading.
77
+ *
78
+ * @returns The human-readable error message extracted from the JSON, or null if
79
+ * the content is not an ADO error response.
80
+ */
81
+ export function extractAdoStreamError(content) {
82
+ try {
83
+ const json = JSON.parse(content.trim());
84
+ if (json && typeof json.typeName === "string" && typeof json.message === "string") {
85
+ return json.message;
86
+ }
87
+ }
88
+ catch {
89
+ // Not JSON — not an ADO error response.
90
+ }
91
+ return null;
92
+ }
70
93
  /**
71
94
  * Convert a Node.js ReadableStream to a string.
72
95
  * Shared utility for consistent stream handling across tools.
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.7.0-nightly.20260427";
1
+ export const packageVersion = "2.7.0-nightly.20260428";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "2.7.0-nightly.20260427",
3
+ "version": "2.7.0-nightly.20260428",
4
4
  "mcpName": "microsoft.com/azure-devops",
5
5
  "description": "MCP server for interacting with Azure DevOps",
6
6
  "license": "MIT",