@azure-devops/mcp 2.10.0-nightly.20260928 → 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,
@@ -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.20260928";
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.20260928",
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",