@azure-devops/mcp 2.10.0-nightly.20260927 → 2.10.0-nightly.20260929

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.
@@ -53,7 +53,9 @@ const TOOL_ANNOTATIONS = {
53
53
  wit_backlog: MUTATING_ANNOTATIONS,
54
54
  wit_query: READ_ONLY_ANNOTATIONS,
55
55
  wit_work_item: READ_ONLY_ANNOTATIONS,
56
- wit_work_item_attachment: ADDITIVE_ANNOTATIONS,
56
+ wit_work_item_attachment: READ_ONLY_ANNOTATIONS,
57
+ wit_work_item_attachment_link: ADDITIVE_ANNOTATIONS,
58
+ wit_work_item_attachment_upload: ADDITIVE_ANNOTATIONS,
57
59
  wit_work_item_comment_write: MUTATING_ANNOTATIONS,
58
60
  wit_work_item_link_write: MUTATING_ANNOTATIONS,
59
61
  wit_work_item_write: MUTATING_ANNOTATIONS,
@@ -8,8 +8,23 @@ import { ConfigurationType, RepositoryType } from "azure-devops-node-api/interfa
8
8
  import { mkdirSync, createWriteStream } from "fs";
9
9
  import { createExternalContentResponse } from "../shared/content-safety.js";
10
10
  import { join, posix, resolve, win32 } from "path";
11
+ import { Readable, Transform } from "stream";
12
+ import { pipeline } from "stream/promises";
11
13
  import { pipelinesWriteShape } from "./pipelines.dto.js";
12
14
  const errorResult = (text) => ({ content: [{ type: "text", text }], isError: true });
15
+ const MAX_ARTIFACT_FILE_BYTES = 1024 * 1024 * 1024;
16
+ const MAX_ARTIFACT_BASE64_BYTES = 10 * 1024 * 1024;
17
+ function getArtifactResponseStream(response) {
18
+ if (!response.body)
19
+ throw new Error("Artifact download returned an empty response body.");
20
+ return Readable.fromWeb(response.body);
21
+ }
22
+ function validateArtifactContentLength(response, maxBytes, limitDescription) {
23
+ const contentLength = Number(response.headers.get("content-length"));
24
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
25
+ throw new Error(`Artifact download exceeds the ${limitDescription} limit.`);
26
+ }
27
+ }
13
28
  async function runPipeline(args, connectionProvider) {
14
29
  if (!args.pipelineId)
15
30
  return errorResult("pipelineId is required for run_pipeline");
@@ -338,25 +353,47 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
338
353
  if (!artifact) {
339
354
  return { content: [{ type: "text", text: `Artifact ${resolvedArtifactName} not found in build ${buildId}.` }], isError: true };
340
355
  }
341
- const fileStream = await buildApi.getArtifactContentZip(project, buildId, resolvedArtifactName);
356
+ if (!artifact.resource?.downloadUrl) {
357
+ throw new Error(`Artifact ${resolvedArtifactName} does not have a download URL.`);
358
+ }
359
+ const accessToken = await tokenProvider();
360
+ const response = await fetch(artifact.resource.downloadUrl, {
361
+ headers: { "Authorization": `Bearer ${accessToken}`, "User-Agent": userAgentProvider() },
362
+ });
363
+ if (!response.ok) {
364
+ throw new Error(`Artifact download failed: ${response.status} ${response.statusText}`);
365
+ }
342
366
  if (destinationPath) {
367
+ validateArtifactContentLength(response, MAX_ARTIFACT_FILE_BYTES, "1 GB file size");
343
368
  const fullDestinationPath = resolve(destinationPath);
344
369
  mkdirSync(fullDestinationPath, { recursive: true });
345
370
  const fileDestinationPath = join(fullDestinationPath, `${resolvedArtifactName}.zip`);
346
371
  const writeStream = createWriteStream(fileDestinationPath);
347
- await new Promise((resolve, reject) => {
348
- fileStream.pipe(writeStream);
349
- fileStream.on("end", () => resolve());
350
- fileStream.on("error", (err) => reject(err));
372
+ let downloadedBytes = 0;
373
+ const sizeLimitStream = new Transform({
374
+ transform(chunk, _encoding, callback) {
375
+ downloadedBytes += chunk.length;
376
+ if (downloadedBytes > MAX_ARTIFACT_FILE_BYTES) {
377
+ callback(new Error("Artifact download exceeds the 1 GB file size limit."));
378
+ return;
379
+ }
380
+ callback(null, chunk);
381
+ },
351
382
  });
383
+ await pipeline(getArtifactResponseStream(response), sizeLimitStream, writeStream);
352
384
  return { content: [{ type: "text", text: `Artifact ${resolvedArtifactName} downloaded to ${destinationPath}.` }] };
353
385
  }
386
+ validateArtifactContentLength(response, MAX_ARTIFACT_BASE64_BYTES, "10 MB base64 response size");
354
387
  const chunks = [];
355
- await new Promise((resolve, reject) => {
356
- fileStream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
357
- fileStream.on("end", () => resolve());
358
- fileStream.on("error", (err) => reject(err));
359
- });
388
+ let downloadedBytes = 0;
389
+ for await (const chunk of getArtifactResponseStream(response)) {
390
+ const bufferChunk = Buffer.from(chunk);
391
+ downloadedBytes += bufferChunk.length;
392
+ if (downloadedBytes > MAX_ARTIFACT_BASE64_BYTES) {
393
+ throw new Error("Artifact download exceeds the 10 MB base64 response size limit.");
394
+ }
395
+ chunks.push(bufferChunk);
396
+ }
360
397
  const buffer = Buffer.concat(chunks);
361
398
  const base64Data = buffer.toString("base64");
362
399
  return {
@@ -1,7 +1,8 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
- import * as fs from "fs";
3
+ import { constants, open, realpath } from "fs/promises";
4
4
  import * as path from "path";
5
+ import { Readable } from "stream";
5
6
  import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
6
7
  import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
7
8
  import { z } from "zod";
@@ -14,6 +15,8 @@ const WORKITEM_TOOLS = {
14
15
  wit_query: "wit_query",
15
16
  wit_backlog: "wit_backlog",
16
17
  wit_work_item_attachment: "wit_work_item_attachment",
18
+ wit_work_item_attachment_upload: "wit_work_item_attachment_upload",
19
+ wit_work_item_attachment_link: "wit_work_item_attachment_link",
17
20
  wit_work_item_write: "wit_work_item_write",
18
21
  wit_work_item_comment_write: "wit_work_item_comment_write",
19
22
  wit_work_item_link_write: "wit_work_item_link_write",
@@ -413,12 +416,12 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
413
416
  // --- wit_work_item_attachment -----------------------------------------------
414
417
  server.tool(WORKITEM_TOOLS.wit_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.", {
415
418
  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."),
416
- attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}"),
419
+ attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}."),
417
420
  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."),
418
421
  savePath: z
419
422
  .string()
420
423
  .optional()
421
- .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."),
424
+ .describe("Optional local directory path where the downloaded file should be saved. Must be a relative path (e.g. 'temp' or 'downloads/attachments') to an existing directory that resolves (including symlinks) inside the server's working directory; absolute paths 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."),
422
425
  }, async ({ project, attachmentId, fileName, savePath }) => {
423
426
  const isAbsolutePath = (value) => path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
424
427
  const hasDriveLetter = (value) => /^[a-zA-Z]:/.test(value);
@@ -428,6 +431,14 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
428
431
  if (fileName !== undefined && fileName.includes("..")) {
429
432
  throw new Error("Invalid fileName: path traversal is not allowed.");
430
433
  }
434
+ if (!attachmentId)
435
+ return { content: [{ type: "text", text: "attachmentId is required for download" }], isError: true };
436
+ if (savePath) {
437
+ const localFileName = fileName ?? attachmentId;
438
+ if (localFileName.includes("\0") || path.posix.basename(localFileName) !== localFileName || path.win32.basename(localFileName) !== localFileName) {
439
+ throw new Error("Invalid fileName: path components are not allowed.");
440
+ }
441
+ }
431
442
  try {
432
443
  const connection = await connectionProvider();
433
444
  let resolvedProject = project;
@@ -447,12 +458,21 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
447
458
  });
448
459
  const buffer = Buffer.concat(chunks);
449
460
  if (savePath) {
450
- const resolvedFileName = fileName ?? attachmentId;
451
- const localFilePath = path.join(savePath, resolvedFileName);
452
- if (fs.existsSync(localFilePath)) {
453
- throw new Error(`File already exists: ${localFilePath}`);
461
+ const workspaceRoot = await realpath(process.cwd());
462
+ const resolvedDirectory = await realpath(path.resolve(workspaceRoot, savePath));
463
+ const relativeDirectory = path.relative(workspaceRoot, resolvedDirectory);
464
+ if (relativeDirectory === ".." || relativeDirectory.startsWith(`..${path.sep}`) || path.isAbsolute(relativeDirectory)) {
465
+ throw new Error("Invalid savePath: destination must remain inside the workspace.");
466
+ }
467
+ const localFilePath = path.join(resolvedDirectory, fileName ?? attachmentId);
468
+ // O_EXCL fails if the file (or a symlink) already exists, so there is no check-then-write race.
469
+ const file = await open(localFilePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600);
470
+ try {
471
+ await file.writeFile(buffer);
472
+ }
473
+ finally {
474
+ await file.close();
454
475
  }
455
- fs.writeFileSync(localFilePath, buffer);
456
476
  return {
457
477
  content: [{ type: "text", text: `Attachment saved to: ${localFilePath}` }],
458
478
  };
@@ -483,6 +503,80 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
483
503
  };
484
504
  }
485
505
  });
506
+ // --- wit_work_item_attachment_upload ----------------------------------------
507
+ server.tool(WORKITEM_TOOLS.wit_work_item_attachment_upload, "Upload base64-encoded content as a new work item attachment. This tool only uploads the attachment; use wit_work_item_attachment_link separately to link the returned attachment URL to a work item.", {
508
+ 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."),
509
+ fileName: z.string().describe("The name of the file to upload, e.g. 'screenshot.png'."),
510
+ content: z.string().describe("Base64-encoded file content to upload."),
511
+ }, async ({ project, fileName, content }) => {
512
+ try {
513
+ if (fileName.includes(".."))
514
+ return { content: [{ type: "text", text: "Invalid fileName: path traversal is not allowed." }], isError: true };
515
+ if (!content)
516
+ return { content: [{ type: "text", text: "content is required for upload" }], isError: true };
517
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(content))
518
+ return { content: [{ type: "text", text: "content must be valid base64-encoded data" }], isError: true };
519
+ const connection = await connectionProvider();
520
+ let resolvedProject = project;
521
+ if (!resolvedProject) {
522
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project to upload the work item attachment to.");
523
+ if ("response" in result)
524
+ return result.response;
525
+ resolvedProject = result.resolved;
526
+ }
527
+ const workItemApi = await connection.getWorkItemTrackingApi();
528
+ const attachmentReference = await workItemApi.createAttachment({}, Readable.from(Buffer.from(content, "base64")), fileName, undefined, resolvedProject);
529
+ return { content: [{ type: "text", text: JSON.stringify(attachmentReference, null, 2) }] };
530
+ }
531
+ catch (error) {
532
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
533
+ return {
534
+ content: [{ type: "text", text: `Error uploading work item attachment: ${errorMessage}` }],
535
+ isError: true,
536
+ };
537
+ }
538
+ });
539
+ // --- wit_work_item_attachment_link ------------------------------------------
540
+ server.tool(WORKITEM_TOOLS.wit_work_item_attachment_link, "Link an uploaded attachment to a work item using the attachment URL returned by wit_work_item_attachment_upload.", {
541
+ 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."),
542
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item to link the uploaded attachment to."),
543
+ attachmentUrl: z.string().url().describe("The attachment URL returned by wit_work_item_attachment_upload."),
544
+ comment: z.string().optional().describe("Optional comment to include with the attachment link."),
545
+ }, async ({ project, workItemId, attachmentUrl, comment }) => {
546
+ try {
547
+ const connection = await connectionProvider();
548
+ let resolvedProject = project;
549
+ if (!resolvedProject) {
550
+ const result = await elicitProject(server, connection, "Select the Azure DevOps project containing the work item to link the attachment to.");
551
+ if ("response" in result)
552
+ return result.response;
553
+ resolvedProject = result.resolved;
554
+ }
555
+ const patchDocument = [
556
+ {
557
+ op: "add",
558
+ path: "/relations/-",
559
+ value: {
560
+ rel: "AttachedFile",
561
+ url: attachmentUrl,
562
+ attributes: { comment: comment || "" },
563
+ },
564
+ },
565
+ ];
566
+ const workItemApi = await connection.getWorkItemTrackingApi();
567
+ const workItem = await workItemApi.updateWorkItem({}, patchDocument, workItemId, resolvedProject);
568
+ if (!workItem)
569
+ throw new Error("Work item not found");
570
+ return { content: [{ type: "text", text: JSON.stringify({ attachmentUrl, workItemId }, null, 2) }] };
571
+ }
572
+ catch (error) {
573
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
574
+ return {
575
+ content: [{ type: "text", text: `Error linking work item attachment: ${errorMessage}` }],
576
+ isError: true,
577
+ };
578
+ }
579
+ });
486
580
  // --- wit_work_item_write ----------------------------------------------------
487
581
  server.tool(WORKITEM_TOOLS.wit_work_item_write, "Write operations for work items. Use the action parameter to specify the operation.", {
488
582
  action: z
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.10.0-nightly.20260927";
1
+ export const packageVersion = "2.10.0-nightly.20260929";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "2.10.0-nightly.20260927",
3
+ "version": "2.10.0-nightly.20260929",
4
4
  "mcpName": "microsoft.com/azure-devops",
5
5
  "description": "MCP server for interacting with Azure DevOps",
6
6
  "license": "MIT",