@azure-devops/mcp 2.8.1-nightly.20260716 → 2.8.1-nightly.20260718

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.
@@ -0,0 +1,34 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ /** Builds an error `CallToolResult`. */
4
+ export const errorResult = (text) => ({ content: [{ type: "text", text }], isError: true });
5
+ /**
6
+ * Routes a validated, action-carrying args object to the matching command.
7
+ *
8
+ * This is what removes long positional parameter lists from grouped ("action")
9
+ * tools: instead of destructuring every possible field, the whole typed args
10
+ * object is forwarded to the single command keyed by `args.action`, coupling
11
+ * each action to exactly one command.
12
+ *
13
+ * - Unknown actions short-circuit with an "Unknown action" error and never
14
+ * touch the context (so no connection is opened).
15
+ * - Errors thrown by a command are caught and formatted using the optional
16
+ * per-action `errorPrefixes` map (falling back to a generic message).
17
+ * - Errors returned by a command (e.g. validation `errorResult`s) pass through
18
+ * unchanged.
19
+ */
20
+ export async function dispatchAction(commands, context, args, errorPrefixes) {
21
+ const command = commands[args.action];
22
+ if (!command) {
23
+ const supportedActions = Object.keys(commands).sort().join(", ");
24
+ return errorResult(`Unknown action: ${args.action}. Supported actions: ${supportedActions}`);
25
+ }
26
+ try {
27
+ return await command.execute(context, args);
28
+ }
29
+ catch (error) {
30
+ const message = error instanceof Error ? error.message : "Unknown error occurred";
31
+ const prefix = errorPrefixes?.[args.action];
32
+ return errorResult(prefix ? `${prefix}${message}` : `Error: ${message}`);
33
+ }
34
+ }
@@ -0,0 +1,95 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { z } from "zod";
4
+ import { getEnumKeys } from "../utils.js";
5
+ import { RepositoryType } from "azure-devops-node-api/interfaces/PipelinesInterfaces.js";
6
+ import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // DTOs for the pipelines_write tool.
9
+ //
10
+ // Each action's inputs are declared once as a Zod "raw shape". The shapes are
11
+ // the single source of truth: the tool's input schema is composed from them,
12
+ // and the TypeScript argument types are derived via `z.infer` (no hand-written,
13
+ // drift-prone duplicate types). These types are safe to export — they are
14
+ // compile-time only and erased at runtime, so they have no effect on the MCP
15
+ // protocol or a local server.
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ export const variableSchema = z.object({
18
+ value: z.string().optional(),
19
+ isSecret: z.boolean().optional(),
20
+ });
21
+ export const resourcesSchema = z.object({
22
+ builds: z.record(z.string(), z.object({ version: z.string().optional() })).optional(),
23
+ containers: z.record(z.string(), z.object({ version: z.string().optional() })).optional(),
24
+ packages: z.record(z.string(), z.object({ version: z.string().optional() })).optional(),
25
+ pipelines: z
26
+ .record(z.string(), z.object({
27
+ runId: z.coerce.number().min(1).optional().describe("Id of the source pipeline run."),
28
+ version: z.string().optional(),
29
+ }))
30
+ .optional(),
31
+ repositories: z
32
+ .record(z.string(), z.object({
33
+ refName: z.string().describe("Reference name, e.g., refs/heads/main."),
34
+ token: z.string().optional(),
35
+ tokenType: z.string().optional(),
36
+ version: z.string().optional(),
37
+ }))
38
+ .optional(),
39
+ });
40
+ /** Fields shared by every write action. */
41
+ const projectShape = {
42
+ project: z.string().describe("Project ID or name."),
43
+ };
44
+ /** run_pipeline inputs. */
45
+ export const runPipelineShape = {
46
+ ...projectShape,
47
+ pipelineId: z.coerce.number().min(1).optional().describe("ID of the pipeline to run. Required for: run_pipeline."),
48
+ pipelineVersion: z.coerce.number().min(1).optional().describe("Version of the pipeline to run. Used for: run_pipeline."),
49
+ previewRun: z.boolean().optional().describe("If true, returns the final YAML without creating a run. Used for: run_pipeline."),
50
+ resources: resourcesSchema.optional().describe("Resources to pass to the pipeline. Used for: run_pipeline."),
51
+ stagesToSkip: z.array(z.string()).optional().describe("Stages to skip. Used for: run_pipeline."),
52
+ templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs. Used for: run_pipeline."),
53
+ variables: z.record(z.string(), variableSchema).optional().describe("Variables to pass to the pipeline. Used for: run_pipeline."),
54
+ yamlOverride: z.string().optional().describe("YAML override (only valid with previewRun). Used for: run_pipeline."),
55
+ };
56
+ /** create_pipeline inputs. */
57
+ export const createPipelineShape = {
58
+ ...projectShape,
59
+ name: z.string().optional().describe("Name of the new pipeline. Required for: create_pipeline."),
60
+ folder: z.string().optional().describe("Folder path for the new pipeline. Used for: create_pipeline."),
61
+ yamlPath: z.string().optional().describe("Path to the YAML file in the repository. Required for: create_pipeline."),
62
+ repositoryType: z
63
+ .enum(getEnumKeys(RepositoryType))
64
+ .optional()
65
+ .describe("Type of the repository. Required for: create_pipeline."),
66
+ repositoryName: z.string().optional().describe("Name of the repository (for GitHub: owner/repo). Required for: create_pipeline."),
67
+ repositoryId: z.string().optional().describe("ID of the repository. Used for: create_pipeline."),
68
+ repositoryConnectionId: z.string().optional().describe("Service connection ID for GitHub repositories. Used for: create_pipeline."),
69
+ };
70
+ /** update_build_stage inputs. */
71
+ export const updateBuildStageShape = {
72
+ ...projectShape,
73
+ buildId: z.coerce.number().min(1).optional().describe("ID of the build to update. Required for: update_build_stage."),
74
+ stageName: z.string().optional().describe("Name of the stage to update. Required for: update_build_stage."),
75
+ status: z
76
+ .enum(getEnumKeys(StageUpdateType))
77
+ .optional()
78
+ .describe("New status for the stage. Required for: update_build_stage."),
79
+ forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage. Used for: update_build_stage."),
80
+ };
81
+ /** The composed input shape for the grouped `pipelines_write` tool. */
82
+ export const pipelinesWriteShape = {
83
+ action: z
84
+ .enum(["run_pipeline", "create_pipeline", "update_build_stage"])
85
+ .describe("The action to perform. Options: run_pipeline (queue a new pipeline run), create_pipeline (create a new YAML pipeline definition), update_build_stage (cancel, retry, or run a stage on an in-flight build)."),
86
+ ...runPipelineShape,
87
+ ...createPipelineShape,
88
+ ...updateBuildStageShape,
89
+ };
90
+ // Per-action schemas + inferred argument DTOs. `z.infer` keeps these types in
91
+ // lockstep with the schemas above.
92
+ export const runPipelineSchema = z.object(runPipelineShape);
93
+ export const createPipelineSchema = z.object(createPipelineShape);
94
+ export const updateBuildStageSchema = z.object(updateBuildStageShape);
95
+ export const pipelinesWriteSchema = z.object(pipelinesWriteShape);
@@ -8,6 +8,113 @@ 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 { dispatchAction, errorResult } from "../shared/command.js";
12
+ import { pipelinesWriteShape } from "./pipelines.dto.js";
13
+ // ─── pipelines_write commands ────────────────────────────────────────────────
14
+ // Each write action is a self-contained command. Shared infrastructure arrives
15
+ // via `CommandContext`; the action-specific input arrives via a single typed
16
+ // args object (see pipelines.dto.ts). This keeps the dispatcher agnostic of
17
+ // individual argument lists.
18
+ const runPipelineCommand = {
19
+ async execute(context, args) {
20
+ if (!args.pipelineId)
21
+ return errorResult("pipelineId is required for run_pipeline");
22
+ if (!args.previewRun && args.yamlOverride)
23
+ throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'.");
24
+ const connection = await context.connectionProvider();
25
+ const pipelinesApi = await connection.getPipelinesApi();
26
+ const runRequest = {
27
+ previewRun: args.previewRun,
28
+ resources: { ...args.resources },
29
+ stagesToSkip: args.stagesToSkip,
30
+ templateParameters: args.templateParameters,
31
+ variables: args.variables,
32
+ yamlOverride: args.yamlOverride,
33
+ };
34
+ const pipelineRun = await pipelinesApi.runPipeline(runRequest, args.project, args.pipelineId, args.pipelineVersion);
35
+ if (pipelineRun.id === undefined)
36
+ throw new Error("Failed to get build ID from pipeline run");
37
+ return { content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }] };
38
+ },
39
+ };
40
+ const createPipelineCommand = {
41
+ async execute(context, args) {
42
+ if (!args.name)
43
+ return errorResult("name is required for create_pipeline");
44
+ if (!args.yamlPath)
45
+ return errorResult("yamlPath is required for create_pipeline");
46
+ if (!args.repositoryType)
47
+ return errorResult("repositoryType is required for create_pipeline");
48
+ if (!args.repositoryName)
49
+ return errorResult("repositoryName is required for create_pipeline");
50
+ const connection = await context.connectionProvider();
51
+ const pipelinesApi = await connection.getPipelinesApi();
52
+ const repositoryTypeEnumValue = safeEnumConvert(RepositoryType, args.repositoryType);
53
+ const repositoryPayload = { type: args.repositoryType };
54
+ if (repositoryTypeEnumValue === RepositoryType.AzureReposGit) {
55
+ repositoryPayload.id = args.repositoryId;
56
+ repositoryPayload.name = args.repositoryName;
57
+ }
58
+ else if (repositoryTypeEnumValue === RepositoryType.GitHub) {
59
+ if (!args.repositoryConnectionId)
60
+ throw new Error("Parameter 'repositoryConnectionId' is required for GitHub repositories.");
61
+ repositoryPayload.connection = { id: args.repositoryConnectionId };
62
+ repositoryPayload.fullname = args.repositoryName;
63
+ }
64
+ else {
65
+ throw new Error("Unsupported repository type");
66
+ }
67
+ const yamlConfigurationType = getEnumKeys(ConfigurationType).find((k) => ConfigurationType[k] === ConfigurationType.Yaml);
68
+ const createParams = {
69
+ name: args.name,
70
+ folder: args.folder || "\\",
71
+ configuration: { type: yamlConfigurationType, path: args.yamlPath, repository: repositoryPayload, variables: undefined },
72
+ };
73
+ const newPipeline = await pipelinesApi.createPipeline(createParams, args.project);
74
+ return { content: [{ type: "text", text: JSON.stringify(newPipeline, null, 2) }] };
75
+ },
76
+ };
77
+ const updateBuildStageCommand = {
78
+ async execute(context, args) {
79
+ if (!args.buildId)
80
+ return errorResult("buildId is required for update_build_stage");
81
+ if (!args.stageName)
82
+ return errorResult("stageName is required for update_build_stage");
83
+ if (!args.status)
84
+ return errorResult("status is required for update_build_stage");
85
+ const connection = await context.connectionProvider();
86
+ const orgUrl = connection.serverUrl;
87
+ const endpoint = `${orgUrl}/${encodeURIComponent(args.project)}/_apis/build/builds/${args.buildId}/stages/${encodeURIComponent(args.stageName)}?api-version=${apiVersion}`;
88
+ const token = await context.tokenProvider();
89
+ const body = { forceRetryAllJobs: args.forceRetryAllJobs, state: safeEnumConvert(StageUpdateType, args.status) };
90
+ const response = await fetch(endpoint, {
91
+ method: "PATCH",
92
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": context.userAgentProvider() },
93
+ body: JSON.stringify(body),
94
+ });
95
+ if (!response.ok) {
96
+ const errorText = await response.text();
97
+ throw new Error(`Failed to update build stage: ${response.status} ${errorText}`);
98
+ }
99
+ const updatedBuild = await response.text();
100
+ return { content: [{ type: "text", text: JSON.stringify(updatedBuild, null, 2) }] };
101
+ },
102
+ };
103
+ /**
104
+ * The registry is the lookup table that couples each action to its command.
105
+ * Adding a new write action means registering one entry here — the dispatcher
106
+ * (`dispatchAction`) never changes.
107
+ */
108
+ const pipelinesWriteCommands = {
109
+ run_pipeline: runPipelineCommand,
110
+ create_pipeline: createPipelineCommand,
111
+ update_build_stage: updateBuildStageCommand,
112
+ };
113
+ const pipelinesWriteErrorPrefixes = {
114
+ run_pipeline: "Error running pipeline: ",
115
+ create_pipeline: "Error creating pipeline: ",
116
+ update_build_stage: "Error updating build stage: ",
117
+ };
11
118
  const PIPELINE_TOOLS = {
12
119
  pipelines_build: "pipelines_build",
13
120
  pipelines_build_log: "pipelines_build_log",
@@ -205,7 +312,6 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
205
312
  return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
206
313
  }
207
314
  });
208
- // ─── pipelines_artifact ─────────────────────────────────────────────────────
209
315
  server.tool(PIPELINE_TOOLS.pipelines_artifact, "Retrieve and download build artifacts. Use the action parameter to specify the operation.", {
210
316
  action: z.enum(["list", "download"]).describe("The action to perform. Options: list (list artifacts for a build), download (download a named build artifact)."),
211
317
  project: z.string().describe("Project ID or name."),
@@ -277,150 +383,10 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
277
383
  return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
278
384
  }
279
385
  });
280
- const variableSchema = z.object({
281
- value: z.string().optional(),
282
- isSecret: z.boolean().optional(),
283
- });
284
- const resourcesSchema = z.object({
285
- builds: z.record(z.string(), z.object({ version: z.string().optional() })).optional(),
286
- containers: z.record(z.string(), z.object({ version: z.string().optional() })).optional(),
287
- packages: z.record(z.string(), z.object({ version: z.string().optional() })).optional(),
288
- pipelines: z.record(z.string(), z.object({
289
- runId: z.coerce.number().min(1).optional().describe("Id of the source pipeline run."),
290
- version: z.string().optional(),
291
- })),
292
- repositories: z
293
- .record(z.string(), z.object({
294
- refName: z.string().describe("Reference name, e.g., refs/heads/main."),
295
- token: z.string().optional(),
296
- tokenType: z.string().optional(),
297
- version: z.string().optional(),
298
- }))
299
- .optional(),
300
- });
301
386
  // ─── pipelines_write ────────────────────────────────────────────────────────
302
- server.tool(PIPELINE_TOOLS.pipelines_write, "Write operations for pipelines and builds. Use the action parameter to specify the operation.", {
303
- action: z
304
- .enum(["run_pipeline", "create_pipeline", "update_build_stage"])
305
- .describe("The action to perform. Options: run_pipeline (queue a new pipeline run), create_pipeline (create a new YAML pipeline definition), update_build_stage (cancel, retry, or run a stage on an in-flight build)."),
306
- project: z.string().describe("Project ID or name."),
307
- // run_pipeline params
308
- pipelineId: z.coerce.number().min(1).optional().describe("ID of the pipeline to run. Required for: run_pipeline."),
309
- pipelineVersion: z.coerce.number().min(1).optional().describe("Version of the pipeline to run. Used for: run_pipeline."),
310
- previewRun: z.boolean().optional().describe("If true, returns the final YAML without creating a run. Used for: run_pipeline."),
311
- resources: resourcesSchema.optional().describe("Resources to pass to the pipeline. Used for: run_pipeline."),
312
- stagesToSkip: z.array(z.string()).optional().describe("Stages to skip. Used for: run_pipeline."),
313
- templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs. Used for: run_pipeline."),
314
- variables: z.record(z.string(), variableSchema).optional().describe("Variables to pass to the pipeline. Used for: run_pipeline."),
315
- yamlOverride: z.string().optional().describe("YAML override (only valid with previewRun). Used for: run_pipeline."),
316
- // create_pipeline params
317
- name: z.string().optional().describe("Name of the new pipeline. Required for: create_pipeline."),
318
- folder: z.string().optional().describe("Folder path for the new pipeline. Used for: create_pipeline."),
319
- yamlPath: z.string().optional().describe("Path to the YAML file in the repository. Required for: create_pipeline."),
320
- repositoryType: z
321
- .enum(getEnumKeys(RepositoryType))
322
- .optional()
323
- .describe("Type of the repository. Required for: create_pipeline."),
324
- repositoryName: z.string().optional().describe("Name of the repository (for GitHub: owner/repo). Required for: create_pipeline."),
325
- repositoryId: z.string().optional().describe("ID of the repository. Used for: create_pipeline."),
326
- repositoryConnectionId: z.string().optional().describe("Service connection ID for GitHub repositories. Used for: create_pipeline."),
327
- // update_build_stage params
328
- buildId: z.coerce.number().min(1).optional().describe("ID of the build to update. Required for: update_build_stage."),
329
- stageName: z.string().optional().describe("Name of the stage to update. Required for: update_build_stage."),
330
- status: z
331
- .enum(getEnumKeys(StageUpdateType))
332
- .optional()
333
- .describe("New status for the stage. Required for: update_build_stage."),
334
- forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage. Used for: update_build_stage."),
335
- }, async ({ action, project, pipelineId, pipelineVersion, previewRun, resources, stagesToSkip, templateParameters, variables, yamlOverride, name, folder, yamlPath, repositoryType, repositoryName, repositoryId, repositoryConnectionId, buildId, stageName, status, forceRetryAllJobs, }) => {
336
- try {
337
- if (action === "run_pipeline") {
338
- if (!pipelineId)
339
- return { content: [{ type: "text", text: "pipelineId is required for run_pipeline" }], isError: true };
340
- if (!previewRun && yamlOverride)
341
- throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'.");
342
- }
343
- if (action === "create_pipeline") {
344
- if (!name)
345
- return { content: [{ type: "text", text: "name is required for create_pipeline" }], isError: true };
346
- if (!yamlPath)
347
- return { content: [{ type: "text", text: "yamlPath is required for create_pipeline" }], isError: true };
348
- if (!repositoryType)
349
- return { content: [{ type: "text", text: "repositoryType is required for create_pipeline" }], isError: true };
350
- if (!repositoryName)
351
- return { content: [{ type: "text", text: "repositoryName is required for create_pipeline" }], isError: true };
352
- }
353
- if (action === "update_build_stage") {
354
- if (!buildId)
355
- return { content: [{ type: "text", text: "buildId is required for update_build_stage" }], isError: true };
356
- if (!stageName)
357
- return { content: [{ type: "text", text: "stageName is required for update_build_stage" }], isError: true };
358
- if (!status)
359
- return { content: [{ type: "text", text: "status is required for update_build_stage" }], isError: true };
360
- }
361
- const connection = await connectionProvider();
362
- if (action === "run_pipeline") {
363
- const pipelinesApi = await connection.getPipelinesApi();
364
- const runRequest = { previewRun, resources: { ...resources }, stagesToSkip, templateParameters, variables, yamlOverride };
365
- const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, pipelineId, pipelineVersion);
366
- if (pipelineRun.id === undefined)
367
- throw new Error("Failed to get build ID from pipeline run");
368
- return { content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }] };
369
- }
370
- if (action === "create_pipeline") {
371
- const pipelinesApi = await connection.getPipelinesApi();
372
- const repositoryTypeEnumValue = safeEnumConvert(RepositoryType, repositoryType);
373
- const repositoryPayload = { type: repositoryType };
374
- if (repositoryTypeEnumValue === RepositoryType.AzureReposGit) {
375
- repositoryPayload.id = repositoryId;
376
- repositoryPayload.name = repositoryName;
377
- }
378
- else if (repositoryTypeEnumValue === RepositoryType.GitHub) {
379
- if (!repositoryConnectionId)
380
- throw new Error("Parameter 'repositoryConnectionId' is required for GitHub repositories.");
381
- repositoryPayload.connection = { id: repositoryConnectionId };
382
- repositoryPayload.fullname = repositoryName;
383
- }
384
- else {
385
- throw new Error("Unsupported repository type");
386
- }
387
- const yamlConfigurationType = getEnumKeys(ConfigurationType).find((k) => ConfigurationType[k] === ConfigurationType.Yaml);
388
- const createParams = {
389
- name,
390
- folder: folder || "\\",
391
- configuration: { type: yamlConfigurationType, path: yamlPath, repository: repositoryPayload, variables: undefined },
392
- };
393
- const newPipeline = await pipelinesApi.createPipeline(createParams, project);
394
- return { content: [{ type: "text", text: JSON.stringify(newPipeline, null, 2) }] };
395
- }
396
- if (action === "update_build_stage") {
397
- const orgUrl = connection.serverUrl;
398
- const endpoint = `${orgUrl}/${encodeURIComponent(project)}/_apis/build/builds/${buildId}/stages/${encodeURIComponent(stageName)}?api-version=${apiVersion}`;
399
- const token = await tokenProvider();
400
- const body = { forceRetryAllJobs, state: safeEnumConvert(StageUpdateType, status) };
401
- const response = await fetch(endpoint, {
402
- method: "PATCH",
403
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": userAgentProvider() },
404
- body: JSON.stringify(body),
405
- });
406
- if (!response.ok) {
407
- const errorText = await response.text();
408
- throw new Error(`Failed to update build stage: ${response.status} ${errorText}`);
409
- }
410
- const updatedBuild = await response.text();
411
- return { content: [{ type: "text", text: JSON.stringify(updatedBuild, null, 2) }] };
412
- }
413
- return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
414
- }
415
- catch (error) {
416
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
417
- const msgs = {
418
- run_pipeline: `Error running pipeline: ${errorMessage}`,
419
- create_pipeline: `Error creating pipeline: ${errorMessage}`,
420
- update_build_stage: `Error updating build stage: ${errorMessage}`,
421
- };
422
- return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true };
423
- }
387
+ server.tool(PIPELINE_TOOLS.pipelines_write, "Write operations for pipelines and builds. Use the action parameter to specify the operation.", pipelinesWriteShape, async (args) => {
388
+ const context = { connectionProvider, tokenProvider, userAgentProvider };
389
+ return dispatchAction(pipelinesWriteCommands, context, args, pipelinesWriteErrorPrefixes);
424
390
  });
425
391
  }
426
- export { PIPELINE_TOOLS, configurePipelineTools };
392
+ export { PIPELINE_TOOLS, configurePipelineTools, runPipelineCommand, createPipelineCommand, updateBuildStageCommand };
@@ -2,35 +2,29 @@
2
2
  // Licensed under the MIT License.
3
3
  import { z } from "zod";
4
4
  import { apiVersion } from "../utils.js";
5
- const Test_Plan_Tools = {
6
- create_test_plan: "testplan_create_test_plan",
7
- create_test_case: "testplan_create_test_case",
8
- update_test_case_steps: "testplan_update_test_case_steps",
9
- add_test_cases_to_suite: "testplan_add_test_cases_to_suite",
5
+ const TEST_PLAN_TOOLS = {
6
+ testplan: "testplan",
10
7
  test_results_from_build_id: "testplan_show_test_results_from_build_id",
11
- list_test_cases: "testplan_list_test_cases",
12
- list_test_plans: "testplan_list_test_plans",
13
- list_test_suites: "testplan_list_test_suites",
14
- create_test_suite: "testplan_create_test_suite",
8
+ testplan_test_plan_write: "testplan_test_plan_write",
9
+ testplan_test_suite_write: "testplan_test_suite_write",
10
+ testplan_test_case_write: "testplan_test_case_write",
15
11
  };
16
12
  function configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider) {
17
- server.tool(Test_Plan_Tools.list_test_plans, "Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.", {
13
+ // ─── testplan (read-only) ────────────────────────────────────────────
14
+ server.tool(TEST_PLAN_TOOLS.testplan, "Retrieve test plan data for a project. Use the action parameter to specify the operation.", {
15
+ action: z
16
+ .enum(["list_plans", "list_suites", "list_cases"])
17
+ .describe("The action to perform. Options: list_plans (list test plans in a project), list_suites (list test suites under a test plan), list_cases (list test cases under a test suite)."),
18
18
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
19
- filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Defaults to true."),
20
- includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan."),
21
- continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
22
- }, async ({ project, filterActivePlans, includePlanDetails, continuationToken }) => {
19
+ filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Used for: list_plans. Defaults to true."),
20
+ includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan. Used for: list_plans."),
21
+ planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: list_suites, list_cases."),
22
+ suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: list_cases."),
23
+ continuationToken: z.string().optional().describe("Token to continue fetching results from a previous request. Used for: list_plans, list_suites, list_cases."),
24
+ }, async ({ action, project, filterActivePlans, includePlanDetails, planId, suiteId, continuationToken }) => {
23
25
  try {
24
26
  const connection = await connectionProvider();
25
27
  const accessToken = await tokenProvider();
26
- const params = new URLSearchParams({ "api-version": apiVersion });
27
- if (filterActivePlans)
28
- params.append("filterActivePlans", "true");
29
- if (includePlanDetails)
30
- params.append("includePlanDetails", "true");
31
- if (continuationToken)
32
- params.append("continuationToken", continuationToken);
33
- const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
34
28
  const headers = {
35
29
  Authorization: `Bearer ${accessToken}`,
36
30
  };
@@ -38,302 +32,109 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
38
32
  if (userAgent) {
39
33
  headers["User-Agent"] = userAgent;
40
34
  }
41
- const response = await fetch(url, {
42
- method: "GET",
43
- headers,
44
- });
45
- if (!response.ok) {
46
- const errorText = await response.text();
47
- throw new Error(`Failed to list test plans (${response.status}): ${errorText}`);
48
- }
49
- const body = await response.json();
50
- const testPlans = body.value ?? [];
51
- const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
52
- const result = {
53
- testPlans: testPlans,
54
- };
55
- if (nextToken) {
56
- result.continuationToken = nextToken;
57
- }
58
- return {
59
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
60
- };
61
- }
62
- catch (error) {
63
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
64
- return {
65
- content: [{ type: "text", text: `Error listing test plans: ${errorMessage}` }],
66
- isError: true,
67
- };
68
- }
69
- });
70
- server.tool(Test_Plan_Tools.create_test_plan, "Creates a new test plan in the project.", {
71
- project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created."),
72
- name: z.string().describe("The name of the test plan to be created."),
73
- iteration: z.string().describe("The iteration path for the test plan"),
74
- description: z.string().optional().describe("The description of the test plan"),
75
- startDate: z.string().optional().describe("The start date of the test plan"),
76
- endDate: z.string().optional().describe("The end date of the test plan"),
77
- areaPath: z.string().optional().describe("The area path for the test plan"),
78
- }, async ({ project, name, iteration, description, startDate, endDate, areaPath }) => {
79
- try {
80
- const connection = await connectionProvider();
81
- const testPlanApi = await connection.getTestPlanApi();
82
- const testPlanToCreate = {
83
- name,
84
- iteration,
85
- description,
86
- startDate: startDate ? new Date(startDate) : undefined,
87
- endDate: endDate ? new Date(endDate) : undefined,
88
- areaPath,
89
- };
90
- const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project);
91
- return {
92
- content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }],
93
- };
94
- }
95
- catch (error) {
96
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
97
- return {
98
- content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }],
99
- isError: true,
100
- };
101
- }
102
- });
103
- server.tool(Test_Plan_Tools.create_test_suite, "Creates a new test suite in a test plan.", {
104
- project: z.string().describe("Project ID or project name"),
105
- planId: z.coerce.number().min(1).describe("ID of the test plan that contains the suites"),
106
- parentSuiteId: z.coerce.number().min(1).describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"),
107
- name: z.string().describe("Name of the child test suite"),
108
- }, async ({ project, planId, parentSuiteId, name }) => {
109
- const maxRetries = 5;
110
- const baseDelay = 500; // milliseconds
111
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
112
- try {
113
- const connection = await connectionProvider();
114
- const testPlanApi = await connection.getTestPlanApi();
115
- const testSuiteToCreate = {
116
- name,
117
- parentSuite: {
118
- id: parentSuiteId,
119
- name: "",
120
- },
121
- suiteType: 2,
122
- };
123
- const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
124
- return {
125
- content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
126
- };
127
- }
128
- catch (error) {
129
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
130
- // Check if it's a concurrency conflict error
131
- const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
132
- // If it's a concurrency error and we have retries left, wait and retry
133
- if (isConcurrencyError && attempt < maxRetries) {
134
- const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200; // Exponential backoff with jitter
135
- await new Promise((resolve) => setTimeout(resolve, delay));
136
- continue; // Retry
35
+ if (action === "list_plans") {
36
+ const params = new URLSearchParams({ "api-version": apiVersion });
37
+ if (filterActivePlans)
38
+ params.append("filterActivePlans", "true");
39
+ if (includePlanDetails)
40
+ params.append("includePlanDetails", "true");
41
+ if (continuationToken)
42
+ params.append("continuationToken", continuationToken);
43
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
44
+ const response = await fetch(url, { method: "GET", headers });
45
+ if (!response.ok) {
46
+ const errorText = await response.text();
47
+ throw new Error(`Failed to list test plans (${response.status}): ${errorText}`);
137
48
  }
138
- // If not a concurrency error or out of retries, return error
139
- return {
140
- content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
141
- isError: true,
142
- };
49
+ const body = await response.json();
50
+ const testPlans = body.value ?? [];
51
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
52
+ const result = { testPlans };
53
+ if (nextToken)
54
+ result.continuationToken = nextToken;
55
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
143
56
  }
144
- }
145
- // This should never be reached, but TypeScript requires a return value
146
- return {
147
- content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }],
148
- isError: true,
149
- };
150
- });
151
- server.tool(Test_Plan_Tools.add_test_cases_to_suite, "Adds existing test cases to a test suite.", {
152
- project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
153
- planId: z.coerce.number().min(1).describe("The ID of the test plan."),
154
- suiteId: z.coerce.number().min(1).describe("The ID of the test suite."),
155
- testCaseIds: z.string().or(z.array(z.string())).describe("The ID(s) of the test case(s) to add. "),
156
- }, async ({ project, planId, suiteId, testCaseIds }) => {
157
- try {
158
- const connection = await connectionProvider();
159
- const testApi = await connection.getTestApi();
160
- // If testCaseIds is an array, convert it to comma-separated string
161
- const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds;
162
- const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString);
163
- return {
164
- content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }],
165
- };
166
- }
167
- catch (error) {
168
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
169
- return {
170
- content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }],
171
- isError: true,
172
- };
173
- }
174
- });
175
- server.tool(Test_Plan_Tools.create_test_case, "Creates a new test case work item.", {
176
- project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
177
- title: z.string().describe("The title of the test case."),
178
- steps: z
179
- .string()
180
- .optional()
181
- .describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result."),
182
- priority: z.coerce.number().optional().describe("The priority of the test case."),
183
- areaPath: z.string().optional().describe("The area path for the test case."),
184
- iterationPath: z.string().optional().describe("The iteration path for the test case."),
185
- testsWorkItemId: z.coerce.number().min(1).optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."),
186
- }, async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => {
187
- try {
188
- const connection = await connectionProvider();
189
- const witClient = await connection.getWorkItemTrackingApi();
190
- let stepsXml;
191
- if (steps) {
192
- stepsXml = convertStepsToXml(steps);
193
- }
194
- // Create JSON patch document for work item
195
- const patchDocument = [];
196
- patchDocument.push({
197
- op: "add",
198
- path: "/fields/System.Title",
199
- value: title,
200
- });
201
- if (testsWorkItemId) {
202
- patchDocument.push({
203
- op: "add",
204
- path: "/relations/-",
205
- value: {
206
- rel: "Microsoft.VSTS.Common.TestedBy-Reverse",
207
- url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`,
208
- },
209
- });
210
- }
211
- if (stepsXml) {
212
- patchDocument.push({
213
- op: "add",
214
- path: "/fields/Microsoft.VSTS.TCM.Steps",
215
- value: stepsXml,
216
- });
217
- }
218
- if (priority) {
219
- patchDocument.push({
220
- op: "add",
221
- path: "/fields/Microsoft.VSTS.Common.Priority",
222
- value: priority,
223
- });
224
- }
225
- if (areaPath) {
226
- patchDocument.push({
227
- op: "add",
228
- path: "/fields/System.AreaPath",
229
- value: areaPath,
230
- });
231
- }
232
- if (iterationPath) {
233
- patchDocument.push({
234
- op: "add",
235
- path: "/fields/System.IterationPath",
236
- value: iterationPath,
57
+ else if (action === "list_suites") {
58
+ if (!planId)
59
+ return { content: [{ type: "text", text: "planId is required for list_suites" }], isError: true };
60
+ const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" });
61
+ if (continuationToken)
62
+ params.append("continuationToken", continuationToken);
63
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`;
64
+ const response = await fetch(url, { method: "GET", headers });
65
+ if (!response.ok) {
66
+ const errorText = await response.text();
67
+ throw new Error(`Failed to list test suites (${response.status}): ${errorText}`);
68
+ }
69
+ const body = await response.json();
70
+ const testSuites = body.value ?? [];
71
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
72
+ const suiteMap = new Map();
73
+ testSuites.forEach((suite) => {
74
+ suiteMap.set(suite.id, {
75
+ id: suite.id,
76
+ name: suite.name,
77
+ parentSuiteId: suite.parentSuite?.id,
78
+ children: [],
79
+ });
237
80
  });
238
- }
239
- const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case");
240
- return {
241
- content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
242
- };
243
- }
244
- catch (error) {
245
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
246
- return {
247
- content: [{ type: "text", text: `Error creating test case: ${errorMessage}` }],
248
- isError: true,
249
- };
250
- }
251
- });
252
- server.tool(Test_Plan_Tools.update_test_case_steps, "Update an existing test case work item.", {
253
- id: z.coerce.number().min(1).describe("The ID of the test case work item to update."),
254
- steps: z
255
- .string()
256
- .describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result."),
257
- }, async ({ id, steps }) => {
258
- try {
259
- const connection = await connectionProvider();
260
- const witClient = await connection.getWorkItemTrackingApi();
261
- let stepsXml;
262
- if (steps) {
263
- stepsXml = convertStepsToXml(steps);
264
- }
265
- // Create JSON patch document for work item
266
- const patchDocument = [];
267
- if (stepsXml) {
268
- patchDocument.push({
269
- op: "add",
270
- path: "/fields/Microsoft.VSTS.TCM.Steps",
271
- value: stepsXml,
81
+ const roots = [];
82
+ suiteMap.forEach((suite) => {
83
+ if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
84
+ suiteMap.get(suite.parentSuiteId).children.push(suite);
85
+ }
86
+ else {
87
+ roots.push(suite);
88
+ }
272
89
  });
90
+ const cleanSuite = (suite) => {
91
+ const cleaned = { id: suite.id, name: suite.name };
92
+ if (suite.children && suite.children.length > 0) {
93
+ cleaned.children = suite.children.map((child) => cleanSuite(child));
94
+ }
95
+ return cleaned;
96
+ };
97
+ const cleanedSuites = roots.map((root) => cleanSuite(root));
98
+ const result = { testSuites: cleanedSuites };
99
+ if (nextToken)
100
+ result.continuationToken = nextToken;
101
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
273
102
  }
274
- const workItem = await witClient.updateWorkItem({}, patchDocument, id);
275
- return {
276
- content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
277
- };
278
- }
279
- catch (error) {
280
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
281
- return {
282
- content: [{ type: "text", text: `Error updating test case steps: ${errorMessage}` }],
283
- isError: true,
284
- };
285
- }
286
- });
287
- server.tool(Test_Plan_Tools.list_test_cases, "Gets a list of test cases in the test plan.", {
288
- project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
289
- planid: z.coerce.number().min(1).describe("The ID of the test plan."),
290
- suiteid: z.coerce.number().min(1).describe("The ID of the test suite."),
291
- continuationToken: z.string().optional().describe("Token to continue fetching test cases from a previous request."),
292
- }, async ({ project, planid, suiteid, continuationToken }) => {
293
- try {
294
- const connection = await connectionProvider();
295
- const accessToken = await tokenProvider();
296
- const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
297
- if (continuationToken)
298
- params.append("continuationToken", continuationToken);
299
- const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planid}/Suites/${suiteid}/TestCase?${params.toString()}`;
300
- const headers = {
301
- Authorization: `Bearer ${accessToken}`,
302
- };
303
- const userAgent = userAgentProvider?.();
304
- if (userAgent) {
305
- headers["User-Agent"] = userAgent;
306
- }
307
- const response = await fetch(url, {
308
- method: "GET",
309
- headers,
310
- });
311
- if (!response.ok) {
312
- const errorText = await response.text();
313
- throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
314
- }
315
- const body = await response.json();
316
- const testcases = body.value ?? [];
317
- const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
318
- const result = {
319
- testCases: testcases,
320
- };
321
- if (nextToken) {
322
- result.continuationToken = nextToken;
103
+ else if (action === "list_cases") {
104
+ if (!planId)
105
+ return { content: [{ type: "text", text: "planId is required for list_cases" }], isError: true };
106
+ if (!suiteId)
107
+ return { content: [{ type: "text", text: "suiteId is required for list_cases" }], isError: true };
108
+ const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
109
+ if (continuationToken)
110
+ params.append("continuationToken", continuationToken);
111
+ const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites/${suiteId}/TestCase?${params.toString()}`;
112
+ const response = await fetch(url, { method: "GET", headers });
113
+ if (!response.ok) {
114
+ const errorText = await response.text();
115
+ throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
116
+ }
117
+ const body = await response.json();
118
+ const testcases = body.value ?? [];
119
+ const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
120
+ const result = { testCases: testcases };
121
+ if (nextToken)
122
+ result.continuationToken = nextToken;
123
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
323
124
  }
324
- return {
325
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
326
- };
125
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
327
126
  }
328
127
  catch (error) {
329
128
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
129
+ const prefix = action === "list_plans" ? "Error listing test plans" : action === "list_suites" ? "Error listing test suites" : "Error listing test cases";
330
130
  return {
331
- content: [{ type: "text", text: `Error listing test cases: ${errorMessage}` }],
131
+ content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
332
132
  isError: true,
333
133
  };
334
134
  }
335
135
  });
336
- server.tool(Test_Plan_Tools.test_results_from_build_id, "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", {
136
+ // ─── testplan_show_test_results_from_build_id ──────────────────────────────────────
137
+ server.tool(TEST_PLAN_TOOLS.test_results_from_build_id, "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", {
337
138
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
338
139
  buildid: z.coerce.number().min(1).describe("The ID of the build."),
339
140
  outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
@@ -341,19 +142,8 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
341
142
  try {
342
143
  const connection = await connectionProvider();
343
144
  const testResultsApi = await connection.getTestResultsApi();
344
- // Build filter expression for outcomes if specified.
345
- // The API accepts: Outcome eq Failed,Passed (unquoted, comma-separated)
346
145
  const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined;
347
- // Fetch test result details for the build in a single API call
348
- // This is more efficient than getTestRuns + getTestResults per run,
349
- // especially for builds with many test runs (e.g., cloud testing with one run per test case)
350
- const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, // publishContext
351
- undefined, // groupBy
352
- outcomeFilter, // filter by outcome
353
- undefined, // orderby
354
- true // shouldIncludeResults - get individual test results, not just aggregates
355
- );
356
- // Extract individual test results from the grouped response
146
+ const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, undefined, outcomeFilter, undefined, true);
357
147
  const allResults = [];
358
148
  if (testResultDetails.resultsForGroup) {
359
149
  for (const group of testResultDetails.resultsForGroup) {
@@ -364,7 +154,6 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
364
154
  }
365
155
  }
366
156
  }
367
- // Format results to extract useful fields
368
157
  const formattedResults = allResults.map((r) => ({
369
158
  id: r.id,
370
159
  testCaseTitle: r.testCaseTitle,
@@ -388,87 +177,204 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
388
177
  };
389
178
  }
390
179
  });
391
- server.tool(Test_Plan_Tools.list_test_suites, "Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.", {
180
+ // ─── testplan_test_plan_write ─────────────────────────────────────────────────────
181
+ server.tool(TEST_PLAN_TOOLS.testplan_test_plan_write, "Write operations for test plans. Use the action parameter to specify the operation.", {
182
+ action: z.enum(["create"]).describe("The action to perform. Options: create (create a new test plan)."),
392
183
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
393
- planId: z.coerce.number().min(1).describe("The ID of the test plan."),
394
- continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
395
- }, async ({ project, planId, continuationToken }) => {
184
+ name: z.string().optional().describe("The name of the test plan. Required for: create."),
185
+ iteration: z.string().optional().describe("The iteration path for the test plan. Required for: create."),
186
+ description: z.string().optional().describe("The description of the test plan. Used for: create."),
187
+ startDate: z.string().optional().describe("The start date of the test plan. Used for: create."),
188
+ endDate: z.string().optional().describe("The end date of the test plan. Used for: create."),
189
+ areaPath: z.string().optional().describe("The area path for the test plan. Used for: create."),
190
+ }, async ({ project, name, iteration, description, startDate, endDate, areaPath }) => {
396
191
  try {
192
+ if (!name)
193
+ return { content: [{ type: "text", text: "name is required for create" }], isError: true };
194
+ if (!iteration)
195
+ return { content: [{ type: "text", text: "iteration is required for create" }], isError: true };
397
196
  const connection = await connectionProvider();
398
- const accessToken = await tokenProvider();
399
- const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" });
400
- if (continuationToken)
401
- params.append("continuationToken", continuationToken);
402
- const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`;
403
- const headers = {
404
- Authorization: `Bearer ${accessToken}`,
197
+ const testPlanApi = await connection.getTestPlanApi();
198
+ const testPlanToCreate = {
199
+ name,
200
+ iteration,
201
+ description,
202
+ startDate: startDate ? new Date(startDate) : undefined,
203
+ endDate: endDate ? new Date(endDate) : undefined,
204
+ areaPath,
405
205
  };
406
- const userAgent = userAgentProvider?.();
407
- if (userAgent) {
408
- headers["User-Agent"] = userAgent;
206
+ const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project);
207
+ return {
208
+ content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }],
209
+ };
210
+ }
211
+ catch (error) {
212
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
213
+ return {
214
+ content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }],
215
+ isError: true,
216
+ };
217
+ }
218
+ });
219
+ // ─── testplan_test_suite_write ────────────────────────────────────────────────────
220
+ server.tool(TEST_PLAN_TOOLS.testplan_test_suite_write, "Write operations for test suites. Use the action parameter to specify the operation.", {
221
+ action: z
222
+ .enum(["create", "add_test_cases"])
223
+ .describe("The action to perform. Options: create (create a new test suite in a test plan), add_test_cases (add existing test cases to a test suite)."),
224
+ project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
225
+ planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: create, add_test_cases."),
226
+ parentSuiteId: z.coerce.number().min(1).optional().describe("ID of the parent suite under which the new suite will be created. Required for: create."),
227
+ name: z.string().optional().describe("Name of the child test suite. Required for: create."),
228
+ suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: add_test_cases."),
229
+ testCaseIds: z.string().or(z.array(z.string())).optional().describe("The ID(s) of the test case(s) to add. Required for: add_test_cases."),
230
+ }, async ({ action, project, planId, parentSuiteId, name, suiteId, testCaseIds }) => {
231
+ try {
232
+ if (action === "create") {
233
+ if (!planId)
234
+ return { content: [{ type: "text", text: "planId is required for create" }], isError: true };
235
+ if (!parentSuiteId)
236
+ return { content: [{ type: "text", text: "parentSuiteId is required for create" }], isError: true };
237
+ if (!name)
238
+ return { content: [{ type: "text", text: "name is required for create" }], isError: true };
239
+ const maxRetries = 5;
240
+ const baseDelay = 500;
241
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
242
+ try {
243
+ const connection = await connectionProvider();
244
+ const testPlanApi = await connection.getTestPlanApi();
245
+ const testSuiteToCreate = {
246
+ name,
247
+ parentSuite: { id: parentSuiteId, name: "" },
248
+ suiteType: 2,
249
+ };
250
+ const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
251
+ return {
252
+ content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
253
+ };
254
+ }
255
+ catch (error) {
256
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
257
+ const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
258
+ if (isConcurrencyError && attempt < maxRetries) {
259
+ const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200;
260
+ await new Promise((resolve) => setTimeout(resolve, delay));
261
+ continue;
262
+ }
263
+ return {
264
+ content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
265
+ isError: true,
266
+ };
267
+ }
268
+ }
269
+ /* istanbul ignore next */
270
+ return {
271
+ content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }],
272
+ isError: true,
273
+ };
409
274
  }
410
- const response = await fetch(url, {
411
- method: "GET",
412
- headers,
413
- });
414
- if (!response.ok) {
415
- const errorText = await response.text();
416
- throw new Error(`Failed to list test suites (${response.status}): ${errorText}`);
275
+ else if (action === "add_test_cases") {
276
+ if (!planId)
277
+ return { content: [{ type: "text", text: "planId is required for add_test_cases" }], isError: true };
278
+ if (!suiteId)
279
+ return { content: [{ type: "text", text: "suiteId is required for add_test_cases" }], isError: true };
280
+ if (!testCaseIds)
281
+ return { content: [{ type: "text", text: "testCaseIds is required for add_test_cases" }], isError: true };
282
+ const connection = await connectionProvider();
283
+ const testApi = await connection.getTestApi();
284
+ const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds;
285
+ const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString);
286
+ return {
287
+ content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }],
288
+ };
417
289
  }
418
- const body = await response.json();
419
- const testSuites = body.value ?? [];
420
- const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
421
- // The API returns a flat list where the root suite is first, followed by all nested suites
422
- // We need to build a proper hierarchy by creating a map and assembling the tree
423
- // Create a map of all suites by ID for quick lookup
424
- const suiteMap = new Map();
425
- testSuites.forEach((suite) => {
426
- suiteMap.set(suite.id, {
427
- id: suite.id,
428
- name: suite.name,
429
- parentSuiteId: suite.parentSuite?.id,
430
- children: [],
431
- });
432
- });
433
- // Build the hierarchy by linking children to parents
434
- const roots = [];
435
- suiteMap.forEach((suite) => {
436
- if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
437
- // This is a child suite, add it to its parent's children array
438
- const parent = suiteMap.get(suite.parentSuiteId);
439
- parent.children.push(suite);
290
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
291
+ }
292
+ catch (error) {
293
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
294
+ return {
295
+ content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }],
296
+ isError: true,
297
+ };
298
+ }
299
+ });
300
+ // ─── testplan_test_case_write ─────────────────────────────────────────────────────
301
+ server.tool(TEST_PLAN_TOOLS.testplan_test_case_write, "Write operations for test cases. Use the action parameter to specify the operation.", {
302
+ action: z.enum(["create", "update_steps"]).describe("The action to perform. Options: create (create a new test case work item), update_steps (update steps on an existing test case)."),
303
+ project: z.string().optional().describe("The unique identifier (ID or name) of the Azure DevOps project. Required for: create."),
304
+ title: z.string().optional().describe("The title of the test case. Required for: create."),
305
+ priority: z.coerce.number().optional().describe("The priority of the test case. Used for: create."),
306
+ areaPath: z.string().optional().describe("The area path for the test case. Used for: create."),
307
+ iterationPath: z.string().optional().describe("The iteration path for the test case. Used for: create."),
308
+ testsWorkItemId: z.coerce.number().min(1).optional().describe("Work item ID to set as a Microsoft.VSTS.Common.TestedBy-Reverse link. Used for: create."),
309
+ id: z.coerce.number().min(1).optional().describe("The ID of the test case work item to update. Required for: update_steps."),
310
+ steps: z
311
+ .string()
312
+ .optional()
313
+ .describe("The steps for the test case. Format each step as '1. Step one|Expected result one\n2. Step two|Expected result two'. Use '|' as the delimiter between step and expected result. Required for: update_steps. Used for: create."),
314
+ }, async ({ action, project, title, steps, priority, areaPath, iterationPath, testsWorkItemId, id }) => {
315
+ try {
316
+ if (action === "create") {
317
+ if (!project)
318
+ return { content: [{ type: "text", text: "project is required for create" }], isError: true };
319
+ if (!title)
320
+ return { content: [{ type: "text", text: "title is required for create" }], isError: true };
321
+ const connection = await connectionProvider();
322
+ const witClient = await connection.getWorkItemTrackingApi();
323
+ let stepsXml;
324
+ if (steps) {
325
+ stepsXml = convertStepsToXml(steps);
440
326
  }
441
- else {
442
- // This is a root suite (no parent or parent not in map)
443
- roots.push(suite);
327
+ const patchDocument = [];
328
+ patchDocument.push({ op: "add", path: "/fields/System.Title", value: title });
329
+ if (testsWorkItemId) {
330
+ patchDocument.push({
331
+ op: "add",
332
+ path: "/relations/-",
333
+ value: {
334
+ rel: "Microsoft.VSTS.Common.TestedBy-Reverse",
335
+ url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`,
336
+ },
337
+ });
444
338
  }
445
- });
446
- // Clean up the output - remove parentSuiteId and empty children arrays
447
- const cleanSuite = (suite) => {
448
- const cleaned = {
449
- id: suite.id,
450
- name: suite.name,
451
- };
452
- if (suite.children && suite.children.length > 0) {
453
- cleaned.children = suite.children.map((child) => cleanSuite(child));
339
+ if (stepsXml) {
340
+ patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
454
341
  }
455
- return cleaned;
456
- };
457
- const cleanedSuites = roots.map((root) => cleanSuite(root));
458
- const result = {
459
- testSuites: cleanedSuites,
460
- };
461
- if (nextToken) {
462
- result.continuationToken = nextToken;
342
+ if (priority) {
343
+ patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: priority });
344
+ }
345
+ if (areaPath) {
346
+ patchDocument.push({ op: "add", path: "/fields/System.AreaPath", value: areaPath });
347
+ }
348
+ if (iterationPath) {
349
+ patchDocument.push({ op: "add", path: "/fields/System.IterationPath", value: iterationPath });
350
+ }
351
+ const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case");
352
+ return {
353
+ content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
354
+ };
463
355
  }
464
- return {
465
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
466
- };
356
+ else if (action === "update_steps") {
357
+ if (!id)
358
+ return { content: [{ type: "text", text: "id is required for update_steps" }], isError: true };
359
+ if (!steps)
360
+ return { content: [{ type: "text", text: "steps is required for update_steps" }], isError: true };
361
+ const connection = await connectionProvider();
362
+ const witClient = await connection.getWorkItemTrackingApi();
363
+ const stepsXml = convertStepsToXml(steps);
364
+ const patchDocument = [];
365
+ patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
366
+ const workItem = await witClient.updateWorkItem({}, patchDocument, id);
367
+ return {
368
+ content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
369
+ };
370
+ }
371
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
467
372
  }
468
373
  catch (error) {
469
374
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
375
+ const prefix = action === "create" ? "Error creating test case" : "Error updating test case steps";
470
376
  return {
471
- content: [{ type: "text", text: `Error listing test suites: ${errorMessage}` }],
377
+ content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
472
378
  isError: true,
473
379
  };
474
380
  }
@@ -480,37 +386,31 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
480
386
  * element, which is the format Azure DevOps expects for rendered step content.
481
387
  */
482
388
  function formatStepContent(text) {
483
- // Convert Markdown markers to HTML tags (** before * and __ before _ to avoid conflicts)
484
389
  const htmlContent = text
485
390
  .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
486
391
  .replace(/\*(.+?)\*/g, "<i>$1</i>")
487
392
  .replace(/__(.+?)__/g, "<u>$1</u>")
488
393
  .replace(/`(.+?)`/g, "<code>$1</code>")
489
394
  .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2">$1</a>');
490
- // Wrap in ADO rich text envelope and XML-escape the entire HTML string
491
395
  return escapeXml(`${htmlContent}`);
492
396
  }
493
397
  /*
494
398
  * Helper function to convert steps text to XML format required
495
399
  */
496
400
  function convertStepsToXml(steps) {
497
- // Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two'
498
401
  const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
499
402
  let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
500
403
  for (let i = 0; i < stepsLines.length; i++) {
501
404
  const stepLine = stepsLines[i].trim();
502
- if (stepLine) {
503
- // Split step and expected result by '|', fallback to default if not provided
504
- const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
505
- const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
506
- const stepText = stepMatch ? stepMatch[2] : stepPart;
507
- const expectedText = expectedPart || "Verify step completes successfully";
508
- xmlSteps += `
405
+ const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
406
+ const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
407
+ const stepText = stepMatch ? stepMatch[2] : stepPart;
408
+ const expectedText = expectedPart || "Verify step completes successfully";
409
+ xmlSteps += `
509
410
  <step id="${i + 1}" type="ActionStep">
510
411
  <parameterizedString isformatted="true">${formatStepContent(stepText)}</parameterizedString>
511
412
  <parameterizedString isformatted="true">${formatStepContent(expectedText)}</parameterizedString>
512
413
  </step>`;
513
- }
514
414
  }
515
415
  xmlSteps += "</steps>";
516
416
  return xmlSteps;
@@ -531,9 +431,10 @@ function escapeXml(unsafe) {
531
431
  return "&apos;";
532
432
  case '"':
533
433
  return "&quot;";
434
+ /* istanbul ignore next */
534
435
  default:
535
436
  return c;
536
437
  }
537
438
  });
538
439
  }
539
- export { Test_Plan_Tools, configureTestPlanTools };
440
+ export { TEST_PLAN_TOOLS, configureTestPlanTools };
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.8.1-nightly.20260716";
1
+ export const packageVersion = "2.8.1-nightly.20260718";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "2.8.1-nightly.20260716",
3
+ "version": "2.8.1-nightly.20260718",
4
4
  "mcpName": "microsoft.com/azure-devops",
5
5
  "description": "MCP server for interacting with Azure DevOps",
6
6
  "license": "MIT",