@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.
- package/dist/shared/tool-annotations.js +3 -1
- package/dist/tools/work-items.js +102 -8
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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,
|
package/dist/tools/work-items.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright (c) Microsoft Corporation.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
-
import
|
|
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
|
|
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
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
1
|
+
export const packageVersion = "2.10.0-nightly.20260929";
|
package/package.json
CHANGED