@azure-devops/mcp 1.1.0 → 1.2.0

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.
@@ -1,7 +1,16 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
+ import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
3
4
  import { z } from "zod";
4
- import { batchApiVersion } from "../utils.js";
5
+ import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
6
+ /**
7
+ * Converts Operation enum key to lowercase string for API usage
8
+ * @param operation The Operation enum key (e.g., "Add", "Replace", "Remove")
9
+ * @returns Lowercase string for API usage (e.g., "add", "replace", "remove")
10
+ */
11
+ function operationToApiString(operation) {
12
+ return operation.toLowerCase();
13
+ }
5
14
  const WORKITEM_TOOLS = {
6
15
  my_work_items: "wit_my_work_items",
7
16
  list_backlogs: "wit_list_backlogs",
@@ -42,6 +51,10 @@ function getLinkTypeFromName(name) {
42
51
  return "Microsoft.VSTS.Common.TestedBy-Forward";
43
52
  case "tests":
44
53
  return "Microsoft.VSTS.Common.TestedBy-Reverse";
54
+ case "affects":
55
+ return "Microsoft.VSTS.Common.Affects-Forward";
56
+ case "affected by":
57
+ return "Microsoft.VSTS.Common.Affects-Reverse";
45
58
  default:
46
59
  throw new Error(`Unknown link type: ${name}`);
47
60
  }
@@ -131,13 +144,30 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
131
144
  project: z.string().describe("The name or ID of the Azure DevOps project."),
132
145
  workItemId: z.number().describe("The ID of the work item to add a comment to."),
133
146
  comment: z.string().describe("The text of the comment to add to the work item."),
134
- }, async ({ project, workItemId, comment }) => {
147
+ format: z.enum(["markdown", "html"]).optional().default("html"),
148
+ }, async ({ project, workItemId, comment, format }) => {
135
149
  const connection = await connectionProvider();
136
- const workItemApi = await connection.getWorkItemTrackingApi();
137
- const commentCreate = { text: comment };
138
- const commentResponse = await workItemApi.addComment(commentCreate, project, workItemId);
150
+ const orgUrl = connection.serverUrl;
151
+ const accessToken = await tokenProvider();
152
+ const body = {
153
+ text: comment,
154
+ };
155
+ const formatParameter = format === "markdown" ? 0 : 1;
156
+ const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
157
+ method: "POST",
158
+ headers: {
159
+ "Authorization": `Bearer ${accessToken.token}`,
160
+ "Content-Type": "application/json",
161
+ "User-Agent": userAgentProvider(),
162
+ },
163
+ body: JSON.stringify(body),
164
+ });
165
+ if (!response.ok) {
166
+ throw new Error(`Failed to add a work item comment: ${response.statusText}}`);
167
+ }
168
+ const comments = await response.text();
139
169
  return {
140
- content: [{ type: "text", text: JSON.stringify(commentResponse, null, 2) }],
170
+ content: [{ type: "text", text: comments }],
141
171
  };
142
172
  });
143
173
  server.tool(WORKITEM_TOOLS.add_child_work_items, "Create one or many child work items from a parent by work item type and parent id.", {
@@ -200,6 +230,13 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
200
230
  value: item.areaPath,
201
231
  });
202
232
  }
233
+ if (item.iterationPath && item.iterationPath.trim().length > 0) {
234
+ ops.push({
235
+ op: "add",
236
+ path: "/fields/System.IterationPath",
237
+ value: item.iterationPath,
238
+ });
239
+ }
203
240
  if (item.format && item.format === "Markdown") {
204
241
  ops.push({
205
242
  op: "add",
@@ -212,13 +249,6 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
212
249
  value: item.format,
213
250
  });
214
251
  }
215
- if (item.iterationPath && item.iterationPath.trim().length > 0) {
216
- ops.push({
217
- op: "add",
218
- path: "/fields/System.IterationPath",
219
- value: item.iterationPath,
220
- });
221
- }
222
252
  return {
223
253
  method: "PATCH",
224
254
  uri: `/${project}/_apis/wit/workitems/$${workItemType}?api-version=${batchApiVersion}`,
@@ -254,17 +284,17 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
254
284
  }
255
285
  });
256
286
  server.tool(WORKITEM_TOOLS.link_work_item_to_pull_request, "Link a single work item to an existing pull request.", {
257
- project: z.string().describe("The name or ID of the Azure DevOps project."),
287
+ projectId: z.string().describe("The project ID of the Azure DevOps project (note: project name is not valid)."),
258
288
  repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."),
259
289
  pullRequestId: z.number().describe("The ID of the pull request to link to."),
260
290
  workItemId: z.number().describe("The ID of the work item to link to the pull request."),
261
- }, async ({ project, repositoryId, pullRequestId, workItemId }) => {
291
+ }, async ({ projectId, repositoryId, pullRequestId, workItemId }) => {
262
292
  try {
263
293
  const connection = await connectionProvider();
264
294
  const workItemTrackingApi = await connection.getWorkItemTrackingApi();
265
295
  // Create artifact link relation using vstfs format
266
296
  // Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId}
267
- const artifactPathValue = `${project}/${repositoryId}/${pullRequestId}`;
297
+ const artifactPathValue = `${projectId}/${repositoryId}/${pullRequestId}`;
268
298
  const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`;
269
299
  // Use the PATCH document format for adding a relation
270
300
  const patchDocument = [
@@ -281,7 +311,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
281
311
  },
282
312
  ];
283
313
  // Use the WorkItem API to update the work item with the new relation
284
- const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, project);
314
+ const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId);
285
315
  if (!workItem) {
286
316
  return { content: [{ type: "text", text: "Work item update failed" }], isError: true };
287
317
  }
@@ -323,15 +353,20 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
323
353
  id: z.number().describe("The ID of the work item to update."),
324
354
  updates: z
325
355
  .array(z.object({
326
- op: z.enum(["add", "replace", "remove"]).default("add").describe("The operation to perform on the field."),
356
+ op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
327
357
  path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
328
- value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."),
358
+ value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."),
329
359
  }))
330
360
  .describe("An array of field updates to apply to the work item."),
331
361
  }, async ({ id, updates }) => {
332
362
  const connection = await connectionProvider();
333
363
  const workItemApi = await connection.getWorkItemTrackingApi();
334
- const updatedWorkItem = await workItemApi.updateWorkItem(null, updates, id);
364
+ // Convert operation names to lowercase for API
365
+ const apiUpdates = updates.map((update) => ({
366
+ ...update,
367
+ op: operationToApiString(update.op),
368
+ }));
369
+ const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id);
335
370
  return {
336
371
  content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }],
337
372
  };
@@ -351,17 +386,33 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
351
386
  project: z.string().describe("The name or ID of the Azure DevOps project."),
352
387
  workItemType: z.string().describe("The type of work item to create, e.g., 'Task', 'Bug', etc."),
353
388
  fields: z
354
- .record(z.string(), z.string())
355
- .describe("A record of field names and values to set on the new work item. Each key is a field name, and each value is the corresponding value to set for that field."),
389
+ .array(z.object({
390
+ name: z.string().describe("The name of the field, e.g., 'System.Title'."),
391
+ value: z.string().describe("The value of the field."),
392
+ format: z.enum(["Html", "Markdown"]).optional().describe("the format of the field value, e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'."),
393
+ }))
394
+ .describe("A record of field names and values to set on the new work item. Each fild is the field name and each value is the corresponding value to set for that field."),
356
395
  }, async ({ project, workItemType, fields }) => {
357
396
  try {
358
397
  const connection = await connectionProvider();
359
398
  const workItemApi = await connection.getWorkItemTrackingApi();
360
- const document = Object.entries(fields).map(([key, value]) => ({
399
+ const document = fields.map(({ name, value }) => ({
361
400
  op: "add",
362
- path: `/fields/${key}`,
363
- value,
401
+ path: `/fields/${name}`,
402
+ value: value,
364
403
  }));
404
+ // Check if any field has format === "Markdown" and add the multilineFieldsFormat operation
405
+ // this should only happen for large text fields, but since we dont't know by field name, lets assume if the users
406
+ // passes a value longer than 50 characters, then we can set the format to Markdown
407
+ fields.forEach(({ name, value, format }) => {
408
+ if (value.length > 50 && format === "Markdown") {
409
+ document.push({
410
+ op: "add",
411
+ path: `/multilineFieldsFormat/${name}`,
412
+ value: "Markdown",
413
+ });
414
+ }
415
+ });
365
416
  const newWorkItem = await workItemApi.createWorkItem(null, document, project, workItemType);
366
417
  if (!newWorkItem) {
367
418
  return { content: [{ type: "text", text: "Work item was not created" }], isError: true };
@@ -381,14 +432,17 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
381
432
  server.tool(WORKITEM_TOOLS.get_query, "Get a query by its ID or path.", {
382
433
  project: z.string().describe("The name or ID of the Azure DevOps project."),
383
434
  query: z.string().describe("The ID or path of the query to retrieve."),
384
- expand: z.enum(["all", "clauses", "minimal", "none", "wiql"]).optional().describe("Optional expand parameter to include additional details in the response. Defaults to 'none'."),
435
+ expand: z
436
+ .enum(getEnumKeys(QueryExpand))
437
+ .optional()
438
+ .describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."),
385
439
  depth: z.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."),
386
440
  includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."),
387
441
  useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."),
388
442
  }, async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => {
389
443
  const connection = await connectionProvider();
390
444
  const workItemApi = await connection.getWorkItemTrackingApi();
391
- const queryDetails = await workItemApi.getQuery(project, query, expand, depth, includeDeleted, useIsoDateFormat);
445
+ const queryDetails = await workItemApi.getQuery(project, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat);
392
446
  return {
393
447
  content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }],
394
448
  };
@@ -411,10 +465,11 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
411
465
  server.tool(WORKITEM_TOOLS.update_work_items_batch, "Update work items in batch", {
412
466
  updates: z
413
467
  .array(z.object({
414
- op: z.enum(["add", "replace", "remove"]).default("add").describe("The operation to perform on the field."),
468
+ op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
415
469
  id: z.number().describe("The ID of the work item to update."),
416
470
  path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
417
471
  value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."),
472
+ format: z.enum(["Html", "Markdown"]).optional().describe("The format of the field value. Only to be used for large text fields. e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'."),
418
473
  }))
419
474
  .describe("An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value)."),
420
475
  }, async ({ updates }) => {
@@ -423,20 +478,32 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
423
478
  const accessToken = await tokenProvider();
424
479
  // Extract unique IDs from the updates array
425
480
  const uniqueIds = Array.from(new Set(updates.map((update) => update.id)));
426
- const body = uniqueIds.map((id) => ({
427
- method: "PATCH",
428
- uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`,
429
- headers: {
430
- "Content-Type": "application/json-patch+json",
431
- },
432
- body: updates
433
- .filter((update) => update.id === id)
434
- .map(({ op, path, value }) => ({
481
+ const body = uniqueIds.map((id) => {
482
+ const workItemUpdates = updates.filter((update) => update.id === id);
483
+ const operations = workItemUpdates.map(({ op, path, value }) => ({
435
484
  op: op,
436
485
  path: path,
437
486
  value: value,
438
- })),
439
- }));
487
+ }));
488
+ // Add format operations for Markdown fields
489
+ workItemUpdates.forEach(({ path, value, format }) => {
490
+ if (format === "Markdown" && value && value.length > 50) {
491
+ operations.push({
492
+ op: "Add",
493
+ path: `/multilineFieldsFormat${path.replace("/fields", "")}`,
494
+ value: "Markdown",
495
+ });
496
+ }
497
+ });
498
+ return {
499
+ method: "PATCH",
500
+ uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`,
501
+ headers: {
502
+ "Content-Type": "application/json-patch+json",
503
+ },
504
+ body: operations,
505
+ };
506
+ });
440
507
  const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
441
508
  method: "PATCH",
442
509
  headers: {
@@ -461,9 +528,9 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
461
528
  id: z.number().describe("The ID of the work item to update."),
462
529
  linkToId: z.number().describe("The ID of the work item to link to."),
463
530
  type: z
464
- .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests"])
531
+ .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"])
465
532
  .default("related")
466
- .describe("Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'referenced by', and 'references'. Defaults to 'related'."),
533
+ .describe("Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', and 'affected by'. Defaults to 'related'."),
467
534
  comment: z.string().optional().describe("Optional comment to include with the link. This can be used to provide additional context for the link being created."),
468
535
  }))
469
536
  .describe(""),
package/dist/useragent.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
1
3
  class UserAgentComposer {
2
4
  _userAgent;
3
5
  _mcpClientInfoAppended;
package/dist/utils.js CHANGED
@@ -2,3 +2,29 @@
2
2
  // Licensed under the MIT License.
3
3
  export const apiVersion = "7.2-preview.1";
4
4
  export const batchApiVersion = "5.0";
5
+ export const markdownCommentsApiVersion = "7.2-preview.4";
6
+ /**
7
+ * Converts a TypeScript numeric enum to an array of string keys for use with z.enum().
8
+ * This ensures that enum schemas generate string values rather than numeric values.
9
+ * @param enumObject The TypeScript enum object
10
+ * @returns Array of string keys from the enum
11
+ */
12
+ export function getEnumKeys(enumObject) {
13
+ return Object.keys(enumObject).filter((key) => isNaN(Number(key)));
14
+ }
15
+ /**
16
+ * Safely converts a string enum key to its corresponding enum value.
17
+ * Validates that the key exists in the enum before conversion.
18
+ * @param enumObject The TypeScript enum object
19
+ * @param key The string key to convert
20
+ * @returns The enum value if key is valid, undefined otherwise
21
+ */
22
+ export function safeEnumConvert(enumObject, key) {
23
+ if (!key)
24
+ return undefined;
25
+ const validKeys = getEnumKeys(enumObject);
26
+ if (!validKeys.includes(key)) {
27
+ return undefined;
28
+ }
29
+ return enumObject[key];
30
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.1.0";
1
+ export const packageVersion = "1.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for interacting with Azure DevOps",
5
5
  "license": "MIT",
6
6
  "author": "Microsoft Corporation",
@@ -26,7 +26,7 @@
26
26
  "build": "tsc && shx chmod +x dist/*.js",
27
27
  "prepare": "npm run build",
28
28
  "watch": "tsc --watch",
29
- "inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
29
+ "inspect": "ALLOWED_ORIGINS=http://127.0.0.1:6274 npx @modelcontextprotocol/inspector node dist/index.js",
30
30
  "start": "node -r tsconfig-paths/register dist/index.js",
31
31
  "eslint": "eslint",
32
32
  "eslint-fix": "eslint --fix",
@@ -37,18 +37,19 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@azure/identity": "^4.10.0",
40
- "@modelcontextprotocol/sdk": "1.13.2",
40
+ "@modelcontextprotocol/sdk": "1.16.0",
41
41
  "azure-devops-extension-api": "^4.252.0",
42
42
  "azure-devops-extension-sdk": "^4.0.2",
43
43
  "azure-devops-node-api": "^15.1.0",
44
+ "yargs": "^18.0.0",
44
45
  "zod": "^3.25.63",
45
46
  "zod-to-json-schema": "^3.24.5"
46
47
  },
47
48
  "devDependencies": {
48
- "@modelcontextprotocol/inspector": "^0.15.0",
49
+ "@modelcontextprotocol/inspector": "^0.16.1",
49
50
  "@types/jest": "^30.0.0",
50
51
  "@types/node": "^22",
51
- "eslint-config-prettier": "10.1.5",
52
+ "eslint-config-prettier": "10.1.8",
52
53
  "eslint-plugin-header": "^3.1.1",
53
54
  "jest": "^30.0.2",
54
55
  "jest-extended": "^6.0.0",