@azure-devops/mcp 2.5.0-nightly.20260323 → 2.5.0-nightly.20260324

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.
@@ -37,7 +37,7 @@ function configureAdvSecTools(server, _, connectionProvider) {
37
37
  .array(z.enum(getEnumKeys(AlertValidityStatus)))
38
38
  .optional()
39
39
  .describe("Filter alerts by validity status. Only applicable for secret alerts."),
40
- top: z.number().optional().default(100).describe("Maximum number of alerts to return. Defaults to 100."),
40
+ top: z.coerce.number().optional().default(100).describe("Maximum number of alerts to return. Defaults to 100."),
41
41
  orderBy: z.enum(["id", "firstSeen", "lastSeen", "fixedOn", "severity"]).optional().default("severity").describe("Order results by specified field. Defaults to 'severity'."),
42
42
  continuationToken: z.string().optional().describe("Continuation token for pagination."),
43
43
  }, async ({ project, repository, alertType, states, severities, ruleId, ruleName, toolName, ref, onlyDefaultBranch, confidenceLevels, validity, top, orderBy, continuationToken }) => {
@@ -79,7 +79,7 @@ function configureAdvSecTools(server, _, connectionProvider) {
79
79
  server.tool(ADVSEC_TOOLS.get_alert_details, "Get detailed information about a specific Advanced Security alert.", {
80
80
  project: z.string().describe("The name or ID of the Azure DevOps project."),
81
81
  repository: z.string().describe("The name or ID of the repository containing the alert."),
82
- alertId: z.number().describe("The ID of the alert to retrieve details for."),
82
+ alertId: z.coerce.number().min(1).describe("The ID of the alert to retrieve details for."),
83
83
  ref: z.string().optional().describe("Git reference (branch) to filter the alert."),
84
84
  }, async ({ project, repository, alertId, ref }) => {
85
85
  try {
@@ -16,8 +16,8 @@ function configureCoreTools(server, tokenProvider, connectionProvider, userAgent
16
16
  server.tool(CORE_TOOLS.list_project_teams, "Retrieve a list of teams for an Azure DevOps project. If a project is not specified, you will be prompted to select one.", {
17
17
  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."),
18
18
  mine: z.boolean().optional().describe("If true, only return teams that the authenticated user is a member of."),
19
- top: z.number().optional().describe("The maximum number of teams to return. Defaults to 100."),
20
- skip: z.number().optional().describe("The number of teams to skip for pagination. Defaults to 0."),
19
+ top: z.coerce.number().optional().describe("The maximum number of teams to return. Defaults to 100."),
20
+ skip: z.coerce.number().optional().describe("The number of teams to skip for pagination. Defaults to 0."),
21
21
  }, async ({ project, mine, top, skip }) => {
22
22
  try {
23
23
  const connection = await connectionProvider();
@@ -47,9 +47,9 @@ function configureCoreTools(server, tokenProvider, connectionProvider, userAgent
47
47
  });
48
48
  server.tool(CORE_TOOLS.list_projects, "Retrieve a list of projects in your Azure DevOps organization.", {
49
49
  stateFilter: z.enum(["all", "wellFormed", "createPending", "deleted"]).default("wellFormed").describe("Filter projects by their state. Defaults to 'wellFormed'."),
50
- top: z.number().optional().describe("The maximum number of projects to return. Defaults to 100."),
51
- skip: z.number().optional().describe("The number of projects to skip for pagination. Defaults to 0."),
52
- continuationToken: z.number().optional().describe("Continuation token for pagination. Used to fetch the next set of results if available."),
50
+ top: z.coerce.number().optional().describe("The maximum number of projects to return. Defaults to 100."),
51
+ skip: z.coerce.number().optional().describe("The number of projects to skip for pagination. Defaults to 0."),
52
+ continuationToken: z.coerce.number().optional().describe("Continuation token for pagination. Used to fetch the next set of results if available."),
53
53
  projectNameFilter: z.string().optional().describe("Filter projects by name. Supports partial matches."),
54
54
  }, async ({ stateFilter, top, skip, continuationToken, projectNameFilter }) => {
55
55
  try {
@@ -37,7 +37,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
37
37
  top: z.number().optional().describe("Maximum number of build definitions to return"),
38
38
  continuationToken: z.string().optional().describe("Token for continuing paged results"),
39
39
  minMetricsTime: z.coerce.date().optional().describe("Minimum metrics time to filter build definitions"),
40
- definitionIds: z.array(z.number()).optional().describe("Array of build definition IDs to filter"),
40
+ definitionIds: z.array(z.coerce.number().min(1)).optional().describe("Array of build definition IDs to filter"),
41
41
  builtAfter: z.coerce.date().optional().describe("Return definitions that have builds after this date"),
42
42
  notBuiltAfter: z.coerce.date().optional().describe("Return definitions that do not have builds after this date"),
43
43
  includeAllProperties: z.boolean().optional().describe("Whether to include all properties in the results"),
@@ -105,7 +105,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
105
105
  });
106
106
  server.tool(PIPELINE_TOOLS.pipelines_get_build_definition_revisions, "Retrieves a list of revisions for a specific build definition.", {
107
107
  project: z.string().describe("Project ID or name to get the build definition revisions for"),
108
- definitionId: z.number().describe("ID of the build definition to get revisions for"),
108
+ definitionId: z.coerce.number().min(1).describe("ID of the build definition to get revisions for"),
109
109
  }, async ({ project, definitionId }) => {
110
110
  const connection = await connectionProvider();
111
111
  const buildApi = await connection.getBuildApi();
@@ -116,8 +116,8 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
116
116
  });
117
117
  server.tool(PIPELINE_TOOLS.pipelines_get_builds, "Retrieves a list of builds for a given project.", {
118
118
  project: z.string().describe("Project ID or name to get builds for"),
119
- definitions: z.array(z.number()).optional().describe("Array of build definition IDs to filter builds"),
120
- queues: z.array(z.number()).optional().describe("Array of queue IDs to filter builds"),
119
+ definitions: z.array(z.coerce.number().min(1)).optional().describe("Array of build definition IDs to filter builds"),
120
+ queues: z.array(z.coerce.number().min(1)).optional().describe("Array of queue IDs to filter builds"),
121
121
  buildNumber: z.string().optional().describe("Build number to filter builds"),
122
122
  minTime: z.coerce.date().optional().describe("Minimum finish time to filter builds"),
123
123
  maxTime: z.coerce.date().optional().describe("Maximum finish time to filter builds"),
@@ -137,7 +137,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
137
137
  .optional()
138
138
  .describe("Order in which builds are returned"),
139
139
  branchName: z.string().optional().describe("Branch name to filter builds"),
140
- buildIds: z.array(z.number()).optional().describe("Array of build IDs to retrieve"),
140
+ buildIds: z.array(z.coerce.number().min(1)).optional().describe("Array of build IDs to retrieve"),
141
141
  repositoryId: z.string().optional().describe("Repository ID to filter builds"),
142
142
  repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter builds"),
143
143
  }, async ({ project, definitions, queues, buildNumber, minTime, maxTime, requestedFor, reasonFilter, statusFilter, resultFilter, tagFilters, properties, top, continuationToken, maxBuildsPerDefinition, deletedFilter, queryOrder, branchName, buildIds, repositoryId, repositoryType, }) => {
@@ -150,7 +150,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
150
150
  });
151
151
  server.tool(PIPELINE_TOOLS.pipelines_get_build_log, "Retrieves the logs for a specific build.", {
152
152
  project: z.string().describe("Project ID or name to get the build log for"),
153
- buildId: z.number().describe("ID of the build to get the log for"),
153
+ buildId: z.coerce.number().min(1).describe("ID of the build to get the log for"),
154
154
  }, async ({ project, buildId }) => {
155
155
  const connection = await connectionProvider();
156
156
  const buildApi = await connection.getBuildApi();
@@ -161,10 +161,10 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
161
161
  });
162
162
  server.tool(PIPELINE_TOOLS.pipelines_get_build_log_by_id, "Get a specific build log by log ID.", {
163
163
  project: z.string().describe("Project ID or name to get the build log for"),
164
- buildId: z.number().describe("ID of the build to get the log for"),
165
- logId: z.number().describe("ID of the log to retrieve"),
166
- startLine: z.number().optional().describe("Starting line number for the log content, defaults to 0"),
167
- endLine: z.number().optional().describe("Ending line number for the log content, defaults to the end of the log"),
164
+ buildId: z.coerce.number().min(1).describe("ID of the build to get the log for"),
165
+ logId: z.coerce.number().min(1).describe("ID of the log to retrieve"),
166
+ startLine: z.coerce.number().optional().describe("Starting line number for the log content, defaults to 0"),
167
+ endLine: z.coerce.number().optional().describe("Ending line number for the log content, defaults to the end of the log"),
168
168
  }, async ({ project, buildId, logId, startLine, endLine }) => {
169
169
  const connection = await connectionProvider();
170
170
  const buildApi = await connection.getBuildApi();
@@ -175,7 +175,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
175
175
  });
176
176
  server.tool(PIPELINE_TOOLS.pipelines_get_build_changes, "Get the changes associated with a specific build.", {
177
177
  project: z.string().describe("Project ID or name to get the build changes for"),
178
- buildId: z.number().describe("ID of the build to get changes for"),
178
+ buildId: z.coerce.number().min(1).describe("ID of the build to get changes for"),
179
179
  continuationToken: z.string().optional().describe("Continuation token for pagination"),
180
180
  top: z.number().default(100).describe("Number of changes to retrieve, defaults to 100"),
181
181
  includeSourceChange: z.boolean().optional().describe("Whether to include source changes in the results, defaults to false"),
@@ -189,8 +189,8 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
189
189
  });
190
190
  server.tool(PIPELINE_TOOLS.pipelines_get_run, "Gets a run for a particular pipeline.", {
191
191
  project: z.string().describe("Project ID or name to run the build in"),
192
- pipelineId: z.number().describe("ID of the pipeline to run"),
193
- runId: z.number().describe("ID of the run to get"),
192
+ pipelineId: z.coerce.number().min(1).describe("ID of the pipeline to run"),
193
+ runId: z.coerce.number().min(1).describe("ID of the run to get"),
194
194
  }, async ({ project, pipelineId, runId }) => {
195
195
  const connection = await connectionProvider();
196
196
  const pipelinesApi = await connection.getPipelinesApi();
@@ -201,7 +201,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
201
201
  });
202
202
  server.tool(PIPELINE_TOOLS.pipelines_list_runs, "Gets top 10000 runs for a particular pipeline.", {
203
203
  project: z.string().describe("Project ID or name to run the build in"),
204
- pipelineId: z.number().describe("ID of the pipeline to run"),
204
+ pipelineId: z.coerce.number().min(1).describe("ID of the pipeline to run"),
205
205
  }, async ({ project, pipelineId }) => {
206
206
  const connection = await connectionProvider();
207
207
  const pipelinesApi = await connection.getPipelinesApi();
@@ -227,7 +227,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
227
227
  }))
228
228
  .optional(),
229
229
  pipelines: z.record(z.string().describe("Name of the pipeline resource."), z.object({
230
- runId: z.number().describe("Id of the source pipeline run that triggered or is referenced by this pipeline run."),
230
+ runId: z.coerce.number().min(1).describe("Id of the source pipeline run that triggered or is referenced by this pipeline run."),
231
231
  version: z.string().optional().describe("Version of the source pipeline run."),
232
232
  })),
233
233
  repositories: z
@@ -241,8 +241,8 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
241
241
  });
242
242
  server.tool(PIPELINE_TOOLS.pipelines_run_pipeline, "Starts a new run of a pipeline.", {
243
243
  project: z.string().describe("Project ID or name to run the build in"),
244
- pipelineId: z.number().describe("ID of the pipeline to run"),
245
- pipelineVersion: z.number().optional().describe("Version of the pipeline to run. If not provided, the latest version will be used."),
244
+ pipelineId: z.coerce.number().min(1).describe("ID of the pipeline to run"),
245
+ pipelineVersion: z.coerce.number().min(1).optional().describe("Version of the pipeline to run. If not provided, the latest version will be used."),
246
246
  previewRun: z.boolean().optional().describe("If true, returns the final YAML document after parsing templates without creating a new run."),
247
247
  resources: resourcesSchema.optional().describe("A dictionary of resources to pass to the pipeline."),
248
248
  stagesToSkip: z.array(z.string()).optional().describe("A list of stages to skip."),
@@ -277,7 +277,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
277
277
  });
278
278
  server.tool(PIPELINE_TOOLS.pipelines_get_build_status, "Fetches the status of a specific build.", {
279
279
  project: z.string().describe("Project ID or name to get the build status for"),
280
- buildId: z.number().describe("ID of the build to get the status for"),
280
+ buildId: z.coerce.number().min(1).describe("ID of the build to get the status for"),
281
281
  }, async ({ project, buildId }) => {
282
282
  const connection = await connectionProvider();
283
283
  const buildApi = await connection.getBuildApi();
@@ -288,14 +288,14 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
288
288
  });
289
289
  server.tool(PIPELINE_TOOLS.pipelines_update_build_stage, "Updates the stage of a specific build.", {
290
290
  project: z.string().describe("Project ID or name to update the build stage for"),
291
- buildId: z.number().describe("ID of the build to update"),
291
+ buildId: z.coerce.number().min(1).describe("ID of the build to update"),
292
292
  stageName: z.string().describe("Name of the stage to update"),
293
293
  status: z.enum(getEnumKeys(StageUpdateType)).describe("New status for the stage"),
294
294
  forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage."),
295
295
  }, async ({ project, buildId, stageName, status, forceRetryAllJobs }) => {
296
296
  const connection = await connectionProvider();
297
297
  const orgUrl = connection.serverUrl;
298
- const endpoint = `${orgUrl}/${project}/_apis/build/builds/${buildId}/stages/${stageName}?api-version=${apiVersion}`;
298
+ const endpoint = `${orgUrl}/${encodeURIComponent(project)}/_apis/build/builds/${buildId}/stages/${encodeURIComponent(stageName)}?api-version=${apiVersion}`;
299
299
  const token = await tokenProvider();
300
300
  const body = {
301
301
  forceRetryAllJobs: forceRetryAllJobs,
@@ -321,7 +321,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
321
321
  });
322
322
  server.tool(PIPELINE_TOOLS.pipelines_list_artifacts, "Lists artifacts for a given build.", {
323
323
  project: z.string().describe("The name or ID of the project."),
324
- buildId: z.number().describe("The ID of the build."),
324
+ buildId: z.coerce.number().min(1).describe("The ID of the build."),
325
325
  }, async ({ project, buildId }) => {
326
326
  const connection = await connectionProvider();
327
327
  const buildApi = await connection.getBuildApi();
@@ -332,16 +332,17 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
332
332
  });
333
333
  server.tool(PIPELINE_TOOLS.pipelines_download_artifact, "Downloads a pipeline artifact.", {
334
334
  project: z.string().describe("The name or ID of the project."),
335
- buildId: z.number().describe("The ID of the build."),
335
+ buildId: z.coerce.number().min(1).describe("The ID of the build."),
336
336
  artifactName: z.string().describe("The name of the artifact to download."),
337
337
  destinationPath: z.string().optional().describe("The local path to download the artifact to. If not provided, returns binary content as base64."),
338
338
  }, async ({ project, buildId, artifactName, destinationPath }) => {
339
339
  const isAbsolutePath = (value) => posix.isAbsolute(value) || win32.isAbsolute(value);
340
+ const hasDriveLetter = (value) => /^[a-zA-Z]:/.test(value);
340
341
  if (artifactName.includes("..")) {
341
342
  throw new Error("Invalid artifactName: path traversal is not allowed.");
342
343
  }
343
- if (destinationPath && (destinationPath.includes("..") || isAbsolutePath(destinationPath))) {
344
- throw new Error("Invalid destinationPath: absolute paths and paths traversals are not allowed.");
344
+ if (destinationPath && (destinationPath.includes("..") || isAbsolutePath(destinationPath) || hasDriveLetter(destinationPath))) {
345
+ throw new Error("Invalid destinationPath: absolute paths and path traversals are not allowed.");
345
346
  }
346
347
  const connection = await connectionProvider();
347
348
  const buildApi = await connection.getBuildApi();
@@ -1,9 +1,9 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
- import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
3
+ import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
4
4
  import { z } from "zod";
5
5
  import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
6
- import { getEnumKeys } from "../utils.js";
6
+ import { getEnumKeys, streamToString } from "../utils.js";
7
7
  const REPO_TOOLS = {
8
8
  list_repos_by_project: "repo_list_repos_by_project",
9
9
  list_pull_requests_by_repo_or_project: "repo_list_pull_requests_by_repo_or_project",
@@ -14,6 +14,7 @@ const REPO_TOOLS = {
14
14
  get_repo_by_name_or_id: "repo_get_repo_by_name_or_id",
15
15
  get_branch_by_name: "repo_get_branch_by_name",
16
16
  get_pull_request_by_id: "repo_get_pull_request_by_id",
17
+ get_pull_request_changes: "repo_get_pull_request_changes",
17
18
  create_pull_request: "repo_create_pull_request",
18
19
  create_branch: "repo_create_branch",
19
20
  update_pull_request: "repo_update_pull_request",
@@ -25,6 +26,7 @@ const REPO_TOOLS = {
25
26
  list_pull_requests_by_commits: "repo_list_pull_requests_by_commits",
26
27
  vote_pull_request: "repo_vote_pull_request",
27
28
  list_directory: "repo_list_directory",
29
+ get_file_content: "repo_get_file_content",
28
30
  };
29
31
  function branchesFilterOutIrrelevantProperties(branches, top) {
30
32
  return branches
@@ -289,7 +291,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
289
291
  });
290
292
  server.tool(REPO_TOOLS.update_pull_request, "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", {
291
293
  repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."),
292
- pullRequestId: z.number().describe("The ID of the pull request to update."),
294
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."),
293
295
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
294
296
  title: z.string().optional().describe("The new title for the pull request."),
295
297
  description: z.string().max(4000).optional().describe("The new description for the pull request. Must not be longer than 4000 characters."),
@@ -392,7 +394,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
392
394
  });
393
395
  server.tool(REPO_TOOLS.update_pull_request_reviewers, "Add or remove reviewers for an existing pull request.", {
394
396
  repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."),
395
- pullRequestId: z.number().describe("The ID of the pull request to update."),
397
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."),
396
398
  reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."),
397
399
  action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."),
398
400
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
@@ -434,8 +436,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
434
436
  });
435
437
  server.tool(REPO_TOOLS.list_repos_by_project, "Retrieve a list of repositories for a given project", {
436
438
  project: z.string().describe("The name or ID of the Azure DevOps project."),
437
- top: z.number().default(100).describe("The maximum number of repositories to return."),
438
- skip: z.number().default(0).describe("The number of repositories to skip. Defaults to 0."),
439
+ top: z.coerce.number().default(100).describe("The maximum number of repositories to return."),
440
+ skip: z.coerce.number().default(0).describe("The number of repositories to skip. Defaults to 0."),
439
441
  repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned."),
440
442
  }, async ({ project, top, skip, repoNameFilter }) => {
441
443
  try {
@@ -472,8 +474,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
472
474
  .optional()
473
475
  .describe("The ID or name of the repository where the pull requests are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
474
476
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID, or to scope the search to a specific project."),
475
- top: z.number().default(100).describe("The maximum number of pull requests to return."),
476
- skip: z.number().default(0).describe("The number of pull requests to skip."),
477
+ top: z.coerce.number().default(100).describe("The maximum number of pull requests to return."),
478
+ skip: z.coerce.number().default(0).describe("The number of pull requests to skip."),
477
479
  created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
478
480
  created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."),
479
481
  i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
@@ -599,12 +601,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
599
601
  repositoryId: z
600
602
  .string()
601
603
  .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
602
- pullRequestId: z.number().describe("The ID of the pull request for which to retrieve threads."),
604
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve threads."),
603
605
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
604
- iteration: z.number().optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."),
605
- baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
606
- top: z.number().default(100).describe("The maximum number of threads to return after filtering."),
607
- skip: z.number().default(0).describe("The number of threads to skip after filtering."),
606
+ iteration: z.coerce.number().min(1).optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."),
607
+ baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
608
+ top: z.coerce.number().default(100).describe("The maximum number of threads to return after filtering."),
609
+ skip: z.coerce.number().default(0).describe("The number of threads to skip after filtering."),
608
610
  fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."),
609
611
  status: z
610
612
  .enum(getEnumKeys(CommentThreadStatus))
@@ -659,11 +661,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
659
661
  repositoryId: z
660
662
  .string()
661
663
  .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
662
- pullRequestId: z.number().describe("The ID of the pull request for which to retrieve thread comments."),
663
- threadId: z.number().describe("The ID of the thread for which to retrieve comments."),
664
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve thread comments."),
665
+ threadId: z.coerce.number().min(1).describe("The ID of the thread for which to retrieve comments."),
664
666
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
665
- top: z.number().default(100).describe("The maximum number of comments to return."),
666
- skip: z.number().default(0).describe("The number of comments to skip."),
667
+ top: z.coerce.number().default(100).describe("The maximum number of comments to return."),
668
+ skip: z.coerce.number().default(0).describe("The number of comments to skip."),
667
669
  fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."),
668
670
  }, async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => {
669
671
  try {
@@ -695,7 +697,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
695
697
  repositoryId: z
696
698
  .string()
697
699
  .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
698
- top: z.number().default(100).describe("The maximum number of branches to return. Defaults to 100."),
700
+ top: z.coerce.number().default(100).describe("The maximum number of branches to return. Defaults to 100."),
699
701
  filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
700
702
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
701
703
  }, async ({ repositoryId, top, filterContains, project }) => {
@@ -720,7 +722,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
720
722
  repositoryId: z
721
723
  .string()
722
724
  .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
723
- top: z.number().default(100).describe("The maximum number of branches to return."),
725
+ top: z.coerce.number().default(100).describe("The maximum number of branches to return."),
724
726
  filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
725
727
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
726
728
  }, async ({ repositoryId, top, filterContains, project }) => {
@@ -805,7 +807,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
805
807
  repositoryId: z
806
808
  .string()
807
809
  .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
808
- pullRequestId: z.number().describe("The ID of the pull request to retrieve."),
810
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to retrieve."),
809
811
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
810
812
  includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."),
811
813
  includeLabels: z.boolean().optional().default(false).describe("Whether to include a summary of labels in the response."),
@@ -855,12 +857,328 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
855
857
  };
856
858
  }
857
859
  });
860
+ server.tool(REPO_TOOLS.get_pull_request_changes, "Get the file changes (diff) for a pull request iteration with actual code diff content. Returns the code changes including line-by-line diffs made in the pull request.", {
861
+ repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
862
+ pullRequestId: z.number().describe("The ID of the pull request to retrieve changes for."),
863
+ iterationId: z.number().optional().describe("The iteration ID to get changes for. If not specified, gets changes for the latest iteration."),
864
+ project: z.string().optional().describe("Project ID or project name (optional)"),
865
+ top: z.number().optional().describe("Maximum number of files to include diffs for. Default is 100."),
866
+ skip: z.number().optional().describe("Number of changes to skip for pagination."),
867
+ compareTo: z.number().optional().describe("Iteration ID to compare against. If specified, returns changes between two iterations."),
868
+ includeDiffs: z.boolean().optional().describe("Whether to include actual line-by-line diff content. Default is true. Set to false to get only file metadata."),
869
+ includeLineContent: z
870
+ .boolean()
871
+ .optional()
872
+ .describe("Whether to include the actual line content from the changed files. Default is true. When true, fetches file content and includes the actual code lines that were added/removed/modified."),
873
+ }, async ({ repositoryId, pullRequestId, iterationId, project, top, skip, compareTo, includeDiffs = true, includeLineContent = true }) => {
874
+ try {
875
+ const connection = await connectionProvider();
876
+ const gitApi = await connection.getGitApi();
877
+ // If repositoryId is a name (not a GUID), we need a project to resolve it.
878
+ // GUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
879
+ const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId);
880
+ if (!isGuid && !project) {
881
+ return {
882
+ content: [
883
+ {
884
+ type: "text",
885
+ text: "Error: When using a repository name instead of a GUID for repositoryId, the 'project' parameter is required. Please either provide the project name/ID, or use repo_get_repo_by_name_or_id to resolve the repository GUID first.",
886
+ },
887
+ ],
888
+ isError: true,
889
+ };
890
+ }
891
+ // If no iteration ID provided, get the latest iteration
892
+ let targetIterationId = iterationId;
893
+ let targetIteration;
894
+ if (targetIterationId == null) {
895
+ const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
896
+ if (!iterations || iterations.length === 0) {
897
+ return {
898
+ content: [{ type: "text", text: "No iterations found for this pull request." }],
899
+ isError: true,
900
+ };
901
+ }
902
+ // Get the latest iteration
903
+ targetIteration = iterations[iterations.length - 1];
904
+ targetIterationId = targetIteration.id;
905
+ }
906
+ else {
907
+ // Get the specific iteration
908
+ targetIteration = await gitApi.getPullRequestIteration(repositoryId, pullRequestId, targetIterationId, project);
909
+ }
910
+ // Get the file change metadata
911
+ const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, targetIterationId ?? 1, project, top, skip, compareTo);
912
+ // If includeDiffs is false, just return the metadata
913
+ if (!includeDiffs) {
914
+ return {
915
+ content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
916
+ };
917
+ }
918
+ // Get actual diff content using getFileDiffs
919
+ if (changes.changeEntries && changes.changeEntries.length > 0 && targetIteration) {
920
+ // Determine base and target commits
921
+ const baseCommitId = compareTo
922
+ ? (await gitApi.getPullRequestIteration(repositoryId, pullRequestId, compareTo, project)).sourceRefCommit?.commitId
923
+ : targetIteration.commonRefCommit?.commitId;
924
+ const targetCommitId = targetIteration.sourceRefCommit?.commitId;
925
+ if (baseCommitId && targetCommitId) {
926
+ // Build FileDiffsCriteria with paths from changeEntries
927
+ // Exclude added and deleted files as they don't have both versions to diff
928
+ // changeType is a flags enum so use bitwise AND to check
929
+ const fileDiffParams = changes.changeEntries
930
+ .filter((entry) => {
931
+ const ct = entry.changeType ?? 0;
932
+ return entry.item?.path && !(ct & VersionControlChangeType.Add) && !(ct & VersionControlChangeType.Delete);
933
+ })
934
+ .map((entry) => {
935
+ // Remove leading slash if present - Azure DevOps API expects relative paths
936
+ const itemPath = entry.item?.path ?? "";
937
+ const path = itemPath.startsWith("/") ? itemPath.substring(1) : itemPath;
938
+ // For renamed/moved files, use the original path from the change entry
939
+ const origPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : path;
940
+ return {
941
+ path: path,
942
+ originalPath: origPath,
943
+ };
944
+ });
945
+ if (fileDiffParams.length > 0) {
946
+ try {
947
+ // Azure DevOps getFileDiffs API accepts max 10 files per request
948
+ const FILE_DIFF_BATCH_SIZE = 10;
949
+ let fileDiffs = [];
950
+ for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) {
951
+ const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE);
952
+ const batchDiffs = await gitApi.getFileDiffs({
953
+ baseVersionCommit: baseCommitId,
954
+ targetVersionCommit: targetCommitId,
955
+ fileDiffParams: batch,
956
+ }, project || "", repositoryId);
957
+ fileDiffs = fileDiffs.concat(batchDiffs);
958
+ }
959
+ // Merge diff content with change metadata
960
+ const enrichedChanges = {
961
+ ...changes,
962
+ changeEntries: changes.changeEntries.map((entry) => {
963
+ // Normalize path for comparison (remove leading slash)
964
+ const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
965
+ const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
966
+ return {
967
+ ...entry,
968
+ diff: matchingDiff || null,
969
+ };
970
+ }),
971
+ };
972
+ // If includeLineContent is true, fetch actual file content with concurrency limit
973
+ if (includeLineContent && enrichedChanges.changeEntries) {
974
+ const CONCURRENCY_LIMIT = 10;
975
+ const entriesWithContent = [...enrichedChanges.changeEntries];
976
+ for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
977
+ const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
978
+ const batchResults = await Promise.all(batch.map(async (entry) => {
979
+ const ct = entry.changeType ?? 0;
980
+ const isAdd = !!(ct & VersionControlChangeType.Add);
981
+ const isDelete = !!(ct & VersionControlChangeType.Delete);
982
+ const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
983
+ if (!entryPath) {
984
+ return entry;
985
+ }
986
+ // Handle added files: fetch full content at target commit and create synthetic diff
987
+ if (isAdd && !entry.diff) {
988
+ try {
989
+ const targetStream = await gitApi
990
+ .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
991
+ .catch(() => null);
992
+ if (targetStream) {
993
+ const targetText = await streamToString(targetStream);
994
+ const targetLines = targetText.split(/\r?\n/);
995
+ return {
996
+ ...entry,
997
+ diff: {
998
+ path: entryPath,
999
+ originalPath: entryPath,
1000
+ lineDiffBlocks: [
1001
+ {
1002
+ changeType: 1, // Add
1003
+ originalLineNumberStart: 0,
1004
+ originalLinesCount: 0,
1005
+ modifiedLineNumberStart: 1,
1006
+ modifiedLinesCount: targetLines.length,
1007
+ modifiedLines: targetLines,
1008
+ },
1009
+ ],
1010
+ },
1011
+ };
1012
+ }
1013
+ }
1014
+ catch (addError) {
1015
+ return {
1016
+ ...entry,
1017
+ _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1018
+ };
1019
+ }
1020
+ return entry;
1021
+ }
1022
+ // Handle deleted files: fetch full content at base commit and create synthetic diff
1023
+ if (isDelete && !entry.diff) {
1024
+ try {
1025
+ const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1026
+ const baseStream = await gitApi
1027
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1028
+ .catch(() => null);
1029
+ if (baseStream) {
1030
+ const baseText = await streamToString(baseStream);
1031
+ const baseLines = baseText.split(/\r?\n/);
1032
+ return {
1033
+ ...entry,
1034
+ diff: {
1035
+ path: entryPath,
1036
+ originalPath: basePath,
1037
+ lineDiffBlocks: [
1038
+ {
1039
+ changeType: 2, // Delete
1040
+ originalLineNumberStart: 1,
1041
+ originalLinesCount: baseLines.length,
1042
+ modifiedLineNumberStart: 0,
1043
+ modifiedLinesCount: 0,
1044
+ originalLines: baseLines,
1045
+ },
1046
+ ],
1047
+ },
1048
+ };
1049
+ }
1050
+ }
1051
+ catch (delError) {
1052
+ return {
1053
+ ...entry,
1054
+ _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1055
+ };
1056
+ }
1057
+ return entry;
1058
+ }
1059
+ // For modified/renamed files, skip if no diff blocks
1060
+ if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1061
+ return entry;
1062
+ }
1063
+ // For renamed/moved files, the base version is at the original path
1064
+ const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1065
+ try {
1066
+ // Fetch file content at both commits
1067
+ const [baseContent, targetContent] = await Promise.all([
1068
+ // Base version (original) - use basePath for renamed files
1069
+ gitApi
1070
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1071
+ .catch(() => null),
1072
+ // Target version (modified)
1073
+ gitApi
1074
+ .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1075
+ .catch(() => null),
1076
+ ]);
1077
+ // Convert streams to text
1078
+ const baseText = baseContent ? await streamToString(baseContent) : "";
1079
+ const targetText = targetContent ? await streamToString(targetContent) : "";
1080
+ // Check if response is an Azure DevOps error (returned as JSON in the stream)
1081
+ const checkForApiError = (text, label) => {
1082
+ if (text.startsWith("{")) {
1083
+ try {
1084
+ const parsed = JSON.parse(text);
1085
+ if (parsed.$id && parsed.innerException !== undefined) {
1086
+ throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1087
+ }
1088
+ }
1089
+ catch (e) {
1090
+ if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1091
+ throw e;
1092
+ // Not valid JSON or not an error response — treat as legitimate content
1093
+ }
1094
+ }
1095
+ };
1096
+ checkForApiError(baseText, "base");
1097
+ checkForApiError(targetText, "target");
1098
+ // Split into lines
1099
+ const baseLines = baseText.split(/\r?\n/);
1100
+ const targetLines = targetText.split(/\r?\n/);
1101
+ // Enrich each lineDiffBlock with actual line content
1102
+ const enrichedDiff = {
1103
+ ...entry.diff,
1104
+ lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1105
+ const enrichedBlock = { ...block };
1106
+ // Add original (base) lines if they exist
1107
+ if (block.originalLineNumberStart && block.originalLinesCount) {
1108
+ const startIdx = block.originalLineNumberStart - 1;
1109
+ const endIdx = startIdx + block.originalLinesCount;
1110
+ enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1111
+ }
1112
+ // Add modified (target) lines if they exist
1113
+ if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1114
+ const startIdx = block.modifiedLineNumberStart - 1;
1115
+ const endIdx = startIdx + block.modifiedLinesCount;
1116
+ enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1117
+ }
1118
+ return enrichedBlock;
1119
+ }),
1120
+ };
1121
+ return {
1122
+ ...entry,
1123
+ diff: enrichedDiff,
1124
+ };
1125
+ }
1126
+ catch (contentError) {
1127
+ // If content fetch fails, return entry with error
1128
+ return {
1129
+ ...entry,
1130
+ _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1131
+ };
1132
+ }
1133
+ }));
1134
+ // Write batch results back into the array
1135
+ for (let j = 0; j < batchResults.length; j++) {
1136
+ entriesWithContent[i + j] = batchResults[j];
1137
+ }
1138
+ }
1139
+ enrichedChanges.changeEntries = entriesWithContent;
1140
+ }
1141
+ return {
1142
+ content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1143
+ };
1144
+ }
1145
+ catch (diffError) {
1146
+ // If diff fetching fails, return metadata with error info
1147
+ return {
1148
+ content: [
1149
+ {
1150
+ type: "text",
1151
+ text: JSON.stringify({
1152
+ ...changes,
1153
+ _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1154
+ _note: "Returned metadata only",
1155
+ }, null, 2),
1156
+ },
1157
+ ],
1158
+ };
1159
+ }
1160
+ }
1161
+ }
1162
+ }
1163
+ // Fallback: return metadata if we couldn't get diffs
1164
+ return {
1165
+ content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
1166
+ };
1167
+ }
1168
+ catch (error) {
1169
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1170
+ return {
1171
+ content: [{ type: "text", text: `Error getting pull request changes: ${errorMessage}` }],
1172
+ isError: true,
1173
+ };
1174
+ }
1175
+ });
858
1176
  server.tool(REPO_TOOLS.reply_to_comment, "Replies to a specific comment on a pull request.", {
859
1177
  repositoryId: z
860
1178
  .string()
861
1179
  .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
862
- pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
863
- threadId: z.number().describe("The ID of the thread to which the comment will be added."),
1180
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1181
+ threadId: z.coerce.number().min(1).describe("The ID of the thread to which the comment will be added."),
864
1182
  content: z.string().describe("The content of the comment to be added."),
865
1183
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
866
1184
  fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."),
@@ -897,7 +1215,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
897
1215
  repositoryId: z
898
1216
  .string()
899
1217
  .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
900
- pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
1218
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
901
1219
  content: z.string().describe("The content of the comment to be added."),
902
1220
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
903
1221
  filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"),
@@ -906,7 +1224,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
906
1224
  .optional()
907
1225
  .default(CommentThreadStatus[CommentThreadStatus.Active])
908
1226
  .describe("The status of the comment thread. Defaults to 'Active'."),
909
- rightFileStartLine: z.number().optional().describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"),
1227
+ rightFileStartLine: z.coerce
1228
+ .number()
1229
+ .min(1)
1230
+ .optional()
1231
+ .describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"),
910
1232
  rightFileStartOffset: z
911
1233
  .number()
912
1234
  .optional()
@@ -1013,8 +1335,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1013
1335
  repositoryId: z
1014
1336
  .string()
1015
1337
  .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
1016
- pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
1017
- threadId: z.number().describe("The ID of the thread to update."),
1338
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1339
+ threadId: z.coerce.number().min(1).describe("The ID of the thread to update."),
1018
1340
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1019
1341
  status: z
1020
1342
  .enum(getEnumKeys(CommentThreadStatus))
@@ -1067,8 +1389,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1067
1389
  .optional()
1068
1390
  .default(GitVersionType[GitVersionType.Branch])
1069
1391
  .describe("The meaning of the version parameter, e.g., branch, tag or commit"),
1070
- skip: z.number().optional().default(0).describe("Number of commits to skip"),
1071
- top: z.number().optional().default(10).describe("Maximum number of commits to return"),
1392
+ skip: z.coerce.number().optional().default(0).describe("Number of commits to skip"),
1393
+ top: z.coerce.number().optional().default(10).describe("Maximum number of commits to return"),
1072
1394
  includeLinks: z.boolean().optional().default(false).describe("Include commit links"),
1073
1395
  includeWorkItems: z.boolean().optional().default(false).describe("Include associated work items"),
1074
1396
  // Enhanced search parameters
@@ -1224,7 +1546,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1224
1546
  });
1225
1547
  server.tool(REPO_TOOLS.vote_pull_request, "Cast a vote on a pull request. Automatically adds the current user as a reviewer if they are not already one.", {
1226
1548
  repositoryId: z.string().describe("The ID or name of the repository. When using a repository name instead of a GUID, the project parameter must also be provided."),
1227
- pullRequestId: z.number().describe("The ID of the pull request."),
1549
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
1228
1550
  vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).describe("The vote to cast: Approved(10), Suggestions(5), None(0), Waiting(-5), Rejected(-10)."),
1229
1551
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1230
1552
  }, async ({ repositoryId, pullRequestId, vote, project }) => {
@@ -1259,7 +1581,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1259
1581
  version: z.string().optional().describe("The version identifier - branch name (e.g., 'main'), tag name, or commit SHA. Defaults to the repository's default branch."),
1260
1582
  versionType: z.enum(["Branch", "Commit", "Tag"]).optional().default("Branch").describe("The type of version identifier: 'Branch', 'Commit', or 'Tag'. Defaults to 'Branch'."),
1261
1583
  recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Defaults to false."),
1262
- recursionDepth: z.number().optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."),
1584
+ recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."),
1263
1585
  }, async ({ repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
1264
1586
  try {
1265
1587
  const connection = await connectionProvider();
@@ -1318,5 +1640,58 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1318
1640
  };
1319
1641
  }
1320
1642
  });
1643
+ // ── Get file content at a specific version (branch, tag, or commit) ──
1644
+ const fileVersionTypeStrings = getEnumKeys(GitVersionType);
1645
+ server.tool(REPO_TOOLS.get_file_content, "Get the content of a file from a Git repository at a specific version (branch, tag, or commit SHA). " +
1646
+ "Useful for reading source files from PR branches, specific commits, or tags without having them checked out locally.", {
1647
+ repositoryId: z.string().describe("The ID (GUID) or name of the repository."),
1648
+ path: z.string().describe("The full path to the file in the repository, e.g., '/src/main.ts' or 'src/main.ts'."),
1649
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
1650
+ version: z
1651
+ .string()
1652
+ .optional()
1653
+ .describe("Version string: branch name (e.g. 'main'), tag name, or commit SHA. " + "Defaults to the repository's default branch if not specified."),
1654
+ versionType: z
1655
+ .enum(fileVersionTypeStrings)
1656
+ .optional()
1657
+ .default("Commit")
1658
+ .describe("How to interpret the 'version' parameter. Defaults to 'Commit'."),
1659
+ }, async ({ repositoryId, path, project, version, versionType }) => {
1660
+ try {
1661
+ const connection = await connectionProvider();
1662
+ const gitApi = await connection.getGitApi();
1663
+ // Build the version descriptor if a version was specified
1664
+ const versionDescriptor = version
1665
+ ? {
1666
+ version: version,
1667
+ versionType: GitVersionType[versionType],
1668
+ }
1669
+ : undefined;
1670
+ // getItemText returns a ReadableStream of the file content as text
1671
+ const stream = await gitApi.getItemText(repositoryId, path, project, undefined, // scopePath
1672
+ undefined, // recursionLevel
1673
+ undefined, // includeContentMetadata
1674
+ undefined, // latestProcessedChange
1675
+ false, // download
1676
+ versionDescriptor, true // includeContent
1677
+ );
1678
+ const content = await streamToString(stream);
1679
+ return {
1680
+ content: [{ type: "text", text: content }],
1681
+ };
1682
+ }
1683
+ catch (error) {
1684
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1685
+ return {
1686
+ content: [
1687
+ {
1688
+ type: "text",
1689
+ text: `Error getting file content for '${path}': ${errorMessage}`,
1690
+ },
1691
+ ],
1692
+ isError: true,
1693
+ };
1694
+ }
1695
+ });
1321
1696
  }
1322
1697
  export { REPO_TOOLS, configureRepoTools };
@@ -17,8 +17,8 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
17
17
  path: z.array(z.string()).optional().describe("Filter by paths"),
18
18
  branch: z.array(z.string()).optional().describe("Filter by branches"),
19
19
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
20
- skip: z.number().default(0).describe("Number of results to skip"),
21
- top: z.number().default(5).describe("Maximum number of results to return"),
20
+ skip: z.coerce.number().default(0).describe("Number of results to skip"),
21
+ top: z.coerce.number().default(5).describe("Maximum number of results to return"),
22
22
  }, async ({ searchText, project, repository, path, branch, includeFacets, skip, top }) => {
23
23
  const accessToken = await tokenProvider();
24
24
  const connection = await connectionProvider();
@@ -66,8 +66,8 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
66
66
  project: z.array(z.string()).optional().describe("Filter by projects"),
67
67
  wiki: z.array(z.string()).optional().describe("Filter by wiki names"),
68
68
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
69
- skip: z.number().default(0).describe("Number of results to skip"),
70
- top: z.number().default(10).describe("Maximum number of results to return"),
69
+ skip: z.coerce.number().default(0).describe("Number of results to skip"),
70
+ top: z.coerce.number().default(10).describe("Maximum number of results to return"),
71
71
  }, async ({ searchText, project, wiki, includeFacets, skip, top }) => {
72
72
  const accessToken = await tokenProvider();
73
73
  const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/wikisearchresults?api-version=${apiVersion}`;
@@ -110,8 +110,8 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
110
110
  state: z.array(z.string()).optional().describe("Filter by work item states"),
111
111
  assignedTo: z.array(z.string()).optional().describe("Filter by assigned to users"),
112
112
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
113
- skip: z.number().default(0).describe("Number of results to skip for pagination"),
114
- top: z.number().default(10).describe("Number of results to return"),
113
+ skip: z.coerce.number().default(0).describe("Number of results to skip for pagination"),
114
+ top: z.coerce.number().default(10).describe("Number of results to return"),
115
115
  }, async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, skip, top }) => {
116
116
  const accessToken = await tokenProvider();
117
117
  const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/workitemsearchresults?api-version=${apiVersion}`;
@@ -72,8 +72,8 @@ function configureTestPlanTools(server, _, connectionProvider) {
72
72
  });
73
73
  server.tool(Test_Plan_Tools.create_test_suite, "Creates a new test suite in a test plan.", {
74
74
  project: z.string().describe("Project ID or project name"),
75
- planId: z.number().describe("ID of the test plan that contains the suites"),
76
- parentSuiteId: z.number().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"),
75
+ planId: z.coerce.number().min(1).describe("ID of the test plan that contains the suites"),
76
+ 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"),
77
77
  name: z.string().describe("Name of the child test suite"),
78
78
  }, async ({ project, planId, parentSuiteId, name }) => {
79
79
  const maxRetries = 5;
@@ -120,8 +120,8 @@ function configureTestPlanTools(server, _, connectionProvider) {
120
120
  });
121
121
  server.tool(Test_Plan_Tools.add_test_cases_to_suite, "Adds existing test cases to a test suite.", {
122
122
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
123
- planId: z.number().describe("The ID of the test plan."),
124
- suiteId: z.number().describe("The ID of the test suite."),
123
+ planId: z.coerce.number().min(1).describe("The ID of the test plan."),
124
+ suiteId: z.coerce.number().min(1).describe("The ID of the test suite."),
125
125
  testCaseIds: z.string().or(z.array(z.string())).describe("The ID(s) of the test case(s) to add. "),
126
126
  }, async ({ project, planId, suiteId, testCaseIds }) => {
127
127
  try {
@@ -149,10 +149,10 @@ function configureTestPlanTools(server, _, connectionProvider) {
149
149
  .string()
150
150
  .optional()
151
151
  .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."),
152
- priority: z.number().optional().describe("The priority of the test case."),
152
+ priority: z.coerce.number().optional().describe("The priority of the test case."),
153
153
  areaPath: z.string().optional().describe("The area path for the test case."),
154
154
  iterationPath: z.string().optional().describe("The iteration path for the test case."),
155
- testsWorkItemId: z.number().optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."),
155
+ 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."),
156
156
  }, async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => {
157
157
  try {
158
158
  const connection = await connectionProvider();
@@ -220,7 +220,7 @@ function configureTestPlanTools(server, _, connectionProvider) {
220
220
  }
221
221
  });
222
222
  server.tool(Test_Plan_Tools.update_test_case_steps, "Update an existing test case work item.", {
223
- id: z.number().describe("The ID of the test case work item to update."),
223
+ id: z.coerce.number().min(1).describe("The ID of the test case work item to update."),
224
224
  steps: z
225
225
  .string()
226
226
  .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."),
@@ -256,8 +256,8 @@ function configureTestPlanTools(server, _, connectionProvider) {
256
256
  });
257
257
  server.tool(Test_Plan_Tools.list_test_cases, "Gets a list of test cases in the test plan.", {
258
258
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
259
- planid: z.number().describe("The ID of the test plan."),
260
- suiteid: z.number().describe("The ID of the test suite."),
259
+ planid: z.coerce.number().min(1).describe("The ID of the test plan."),
260
+ suiteid: z.coerce.number().min(1).describe("The ID of the test suite."),
261
261
  }, async ({ project, planid, suiteid }) => {
262
262
  try {
263
263
  const connection = await connectionProvider();
@@ -277,7 +277,7 @@ function configureTestPlanTools(server, _, connectionProvider) {
277
277
  });
278
278
  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.", {
279
279
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
280
- buildid: z.number().describe("The ID of the build."),
280
+ buildid: z.coerce.number().min(1).describe("The ID of the build."),
281
281
  outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
282
282
  }, async ({ project, buildid, outcomes }) => {
283
283
  try {
@@ -329,7 +329,7 @@ function configureTestPlanTools(server, _, connectionProvider) {
329
329
  });
330
330
  server.tool(Test_Plan_Tools.list_test_suites, "Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.", {
331
331
  project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
332
- planId: z.number().describe("The ID of the test plan."),
332
+ planId: z.coerce.number().min(1).describe("The ID of the test plan."),
333
333
  continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."),
334
334
  }, async ({ project, planId, continuationToken }) => {
335
335
  try {
@@ -59,9 +59,9 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
59
59
  server.tool(WIKI_TOOLS.list_wiki_pages, "Retrieve a list of wiki pages for a specific wiki and project.", {
60
60
  wikiIdentifier: z.string().describe("The unique identifier of the wiki."),
61
61
  project: z.string().describe("The project name or ID where the wiki is located."),
62
- top: z.number().default(20).describe("The maximum number of pages to return. Defaults to 20."),
62
+ top: z.coerce.number().default(20).describe("The maximum number of pages to return. Defaults to 20."),
63
63
  continuationToken: z.string().optional().describe("Token for pagination to retrieve the next set of pages."),
64
- pageViewsForDays: z.number().optional().describe("Number of days to retrieve page views for. If not specified, page views are not included."),
64
+ pageViewsForDays: z.coerce.number().optional().describe("Number of days to retrieve page views for. If not specified, page views are not included."),
65
65
  }, async ({ wikiIdentifier, project, top = 20, continuationToken, pageViewsForDays }) => {
66
66
  try {
67
67
  const connection = await connectionProvider();
@@ -111,7 +111,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
111
111
  if (recursionLevel) {
112
112
  params.append("recursionLevel", recursionLevel);
113
113
  }
114
- const url = `${baseUrl}/${project}/_apis/wiki/wikis/${wikiIdentifier}/pages?${params.toString()}`;
114
+ const url = `${baseUrl}/${encodeURIComponent(project)}/_apis/wiki/wikis/${encodeURIComponent(wikiIdentifier)}/pages?${params.toString()}`;
115
115
  const response = await fetch(url, {
116
116
  headers: {
117
117
  "Authorization": `Bearer ${accessToken}`,
@@ -173,7 +173,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
173
173
  try {
174
174
  const accessToken = await tokenProvider();
175
175
  const baseUrl = connection.serverUrl.replace(/\/$/, "");
176
- const restUrl = `${baseUrl}/${resolvedProject}/_apis/wiki/wikis/${resolvedWiki}/pages/${parsed.pageId}?includeContent=true&api-version=7.1`;
176
+ const restUrl = `${baseUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(resolvedWiki)}/pages/${parsed.pageId}?includeContent=true&api-version=7.1`;
177
177
  const resp = await fetch(restUrl, {
178
178
  headers: {
179
179
  "Authorization": `Bearer ${accessToken}`,
@@ -236,7 +236,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
236
236
  // Build the URL for the wiki page API with version descriptor
237
237
  const baseUrl = connection.serverUrl;
238
238
  const projectParam = project || "";
239
- const url = `${baseUrl}/${projectParam}/_apis/wiki/wikis/${wikiIdentifier}/pages?path=${encodedPath}&versionDescriptor.versionType=branch&versionDescriptor.version=${encodeURIComponent(branch)}&api-version=7.1`;
239
+ const url = `${baseUrl}/${encodeURIComponent(projectParam)}/_apis/wiki/wikis/${encodeURIComponent(wikiIdentifier)}/pages?path=${encodedPath}&versionDescriptor.versionType=branch&versionDescriptor.version=${encodeURIComponent(branch)}&api-version=7.1`;
240
240
  // First, try to create a new page (PUT without ETag)
241
241
  try {
242
242
  const createResponse = await fetch(url, {
@@ -104,7 +104,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
104
104
  server.tool(WORKITEM_TOOLS.my_work_items, "Retrieve a list of work items relevent to the authenticated user.", {
105
105
  project: z.string().describe("The name or ID of the Azure DevOps project."),
106
106
  type: z.enum(["assignedtome", "myactivity"]).default("assignedtome").describe("The type of work items to retrieve. Defaults to 'assignedtome'."),
107
- top: z.number().default(50).describe("The maximum number of work items to return. Defaults to 50."),
107
+ top: z.coerce.number().default(50).describe("The maximum number of work items to return. Defaults to 50."),
108
108
  includeCompleted: z.boolean().default(false).describe("Whether to include completed work items. Defaults to false."),
109
109
  }, async ({ project, type, top, includeCompleted }) => {
110
110
  try {
@@ -125,7 +125,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
125
125
  });
126
126
  server.tool(WORKITEM_TOOLS.get_work_items_batch_by_ids, "Retrieve list of work items by IDs in batch.", {
127
127
  project: z.string().describe("The name or ID of the Azure DevOps project."),
128
- ids: z.array(z.number()).describe("The IDs of the work items to retrieve."),
128
+ ids: z.array(z.coerce.number().min(1)).describe("The IDs of the work items to retrieve."),
129
129
  fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used."),
130
130
  }, async ({ project, ids, fields }) => {
131
131
  try {
@@ -174,7 +174,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
174
174
  }
175
175
  });
176
176
  server.tool(WORKITEM_TOOLS.get_work_item, "Get a single work item by ID.", {
177
- id: z.number().describe("The ID of the work item to retrieve."),
177
+ id: z.coerce.number().min(1).describe("The ID of the work item to retrieve."),
178
178
  project: z.string().describe("The name or ID of the Azure DevOps project."),
179
179
  fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, all fields will be returned."),
180
180
  asOf: z.coerce.date().optional().describe("Optional date string to retrieve the work item as of a specific time. If not provided, the current state will be returned."),
@@ -202,8 +202,8 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
202
202
  });
203
203
  server.tool(WORKITEM_TOOLS.list_work_item_comments, "Retrieve list of comments for a work item by ID.", {
204
204
  project: z.string().describe("The name or ID of the Azure DevOps project."),
205
- workItemId: z.number().describe("The ID of the work item to retrieve comments for."),
206
- top: z.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."),
205
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item to retrieve comments for."),
206
+ top: z.coerce.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."),
207
207
  }, async ({ project, workItemId, top }) => {
208
208
  try {
209
209
  const connection = await connectionProvider();
@@ -223,7 +223,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
223
223
  });
224
224
  server.tool(WORKITEM_TOOLS.add_work_item_comment, "Add comment to a work item by ID.", {
225
225
  project: z.string().describe("The name or ID of the Azure DevOps project."),
226
- workItemId: z.number().describe("The ID of the work item to add a comment to."),
226
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item to add a comment to."),
227
227
  comment: z.string().describe("The text of the comment to add to the work item."),
228
228
  format: z.enum(["markdown", "html"]).optional().default("html"),
229
229
  }, async ({ project, workItemId, comment, format }) => {
@@ -235,7 +235,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
235
235
  text: comment,
236
236
  };
237
237
  const formatParameter = format === "markdown" ? 0 : 1;
238
- const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
238
+ const response = await fetch(`${orgUrl}/${encodeURIComponent(project)}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
239
239
  method: "POST",
240
240
  headers: {
241
241
  "Authorization": `Bearer ${accessToken}`,
@@ -262,8 +262,8 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
262
262
  });
263
263
  server.tool(WORKITEM_TOOLS.update_work_item_comment, "Update an existing comment on a work item by ID.", {
264
264
  project: z.string().describe("The name or ID of the Azure DevOps project."),
265
- workItemId: z.number().describe("The ID of the work item."),
266
- commentId: z.number().describe("The ID of the comment to update."),
265
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item."),
266
+ commentId: z.coerce.number().min(1).describe("The ID of the comment to update."),
267
267
  text: z.string().describe("The updated comment text."),
268
268
  format: z.enum(["markdown", "html"]).optional().default("html"),
269
269
  }, async ({ project, workItemId, commentId, text, format }) => {
@@ -273,7 +273,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
273
273
  const accessToken = await tokenProvider();
274
274
  const body = { text };
275
275
  const formatParameter = format === "markdown" ? 0 : 1;
276
- const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
276
+ const response = await fetch(`${orgUrl}/${encodeURIComponent(project)}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
277
277
  method: "PATCH",
278
278
  headers: {
279
279
  "Authorization": `Bearer ${accessToken}`,
@@ -300,9 +300,9 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
300
300
  });
301
301
  server.tool(WORKITEM_TOOLS.list_work_item_revisions, "Retrieve list of revisions for a work item by ID.", {
302
302
  project: z.string().describe("The name or ID of the Azure DevOps project."),
303
- workItemId: z.number().describe("The ID of the work item to retrieve revisions for."),
304
- top: z.number().default(50).describe("Optional number of revisions to retrieve. If not provided, all revisions will be returned."),
305
- skip: z.number().optional().describe("Optional number of revisions to skip for pagination. Defaults to 0."),
303
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item to retrieve revisions for."),
304
+ top: z.coerce.number().default(50).describe("Optional number of revisions to retrieve. If not provided, all revisions will be returned."),
305
+ skip: z.coerce.number().optional().describe("Optional number of revisions to skip for pagination. Defaults to 0."),
306
306
  expand: z
307
307
  .enum(getEnumKeys(WorkItemExpand))
308
308
  .default("None")
@@ -351,7 +351,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
351
351
  }
352
352
  });
353
353
  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.", {
354
- parentId: z.number().describe("The ID of the parent work item to create a child work item under."),
354
+ parentId: z.coerce.number().min(1).describe("The ID of the parent work item to create a child work item under."),
355
355
  project: z.string().describe("The name or ID of the Azure DevOps project."),
356
356
  workItemType: z.string().describe("The type of the child work item to create."),
357
357
  items: z.array(z.object({
@@ -432,7 +432,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
432
432
  }
433
433
  return {
434
434
  method: "PATCH",
435
- uri: `/${project}/_apis/wit/workitems/$${workItemType}?api-version=${batchApiVersion}`,
435
+ uri: `/${encodeURIComponent(project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}?api-version=${batchApiVersion}`,
436
436
  headers: {
437
437
  "Content-Type": "application/json-patch+json",
438
438
  },
@@ -467,8 +467,8 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
467
467
  server.tool(WORKITEM_TOOLS.link_work_item_to_pull_request, "Link a single work item to an existing pull request.", {
468
468
  projectId: z.string().describe("The project ID of the Azure DevOps project (note: project name is not valid)."),
469
469
  repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."),
470
- pullRequestId: z.number().describe("The ID of the pull request to link to."),
471
- workItemId: z.number().describe("The ID of the work item to link to the pull request."),
470
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to link to."),
471
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item to link to the pull request."),
472
472
  pullRequestProjectId: z.string().optional().describe("The project ID containing the pull request. If not provided, defaults to the work item's project ID (for same-project linking)."),
473
473
  }, async ({ projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId }) => {
474
474
  try {
@@ -542,7 +542,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
542
542
  }
543
543
  });
544
544
  server.tool(WORKITEM_TOOLS.update_work_item, "Update a work item by ID with specified fields.", {
545
- id: z.number().describe("The ID of the work item to update."),
545
+ id: z.coerce.number().min(1).describe("The ID of the work item to update."),
546
546
  updates: z
547
547
  .array(z.object({
548
548
  op: z
@@ -651,7 +651,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
651
651
  .enum(getEnumKeys(QueryExpand))
652
652
  .optional()
653
653
  .describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."),
654
- depth: z.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."),
654
+ depth: z.coerce.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."),
655
655
  includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."),
656
656
  useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."),
657
657
  }, async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => {
@@ -676,7 +676,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
676
676
  project: z.string().optional().describe("The name or ID of the Azure DevOps project. If not provided, the default project will be used."),
677
677
  team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."),
678
678
  timePrecision: z.boolean().optional().describe("Whether to include time precision in the results. Defaults to false."),
679
- top: z.number().default(50).describe("The maximum number of results to return. Defaults to 50."),
679
+ top: z.coerce.number().default(50).describe("The maximum number of results to return. Defaults to 50."),
680
680
  responseType: z.enum(["full", "ids"]).default("full").describe("Response type: 'full' returns complete query results (default), 'ids' returns only work item IDs for reduced payload size."),
681
681
  }, async ({ id, project, team, timePrecision, top, responseType }) => {
682
682
  try {
@@ -708,7 +708,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
708
708
  updates: z
709
709
  .array(z.object({
710
710
  op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."),
711
- id: z.number().describe("The ID of the work item to update."),
711
+ id: z.coerce.number().min(1).describe("The ID of the work item to update."),
712
712
  path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."),
713
713
  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."),
714
714
  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'."),
@@ -776,8 +776,8 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
776
776
  project: z.string().describe("The name or ID of the Azure DevOps project."),
777
777
  updates: z
778
778
  .array(z.object({
779
- id: z.number().describe("The ID of the work item to update."),
780
- linkToId: z.number().describe("The ID of the work item to link to."),
779
+ id: z.coerce.number().min(1).describe("The ID of the work item to update."),
780
+ linkToId: z.coerce.number().min(1).describe("The ID of the work item to link to."),
781
781
  type: z
782
782
  .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"])
783
783
  .default("related")
@@ -839,7 +839,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
839
839
  });
840
840
  server.tool(WORKITEM_TOOLS.work_item_unlink, "Remove one or many links from a single work item", {
841
841
  project: z.string().describe("The name or ID of the Azure DevOps project."),
842
- id: z.number().describe("The ID of the work item to remove the links from."),
842
+ id: z.coerce.number().min(1).describe("The ID of the work item to remove the links from."),
843
843
  type: z
844
844
  .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"])
845
845
  .default("related")
@@ -902,7 +902,7 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
902
902
  }
903
903
  });
904
904
  server.tool(WORKITEM_TOOLS.add_artifact_link, "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically.", {
905
- workItemId: z.number().describe("The ID of the work item to add the artifact link to."),
905
+ workItemId: z.coerce.number().min(1).describe("The ID of the work item to add the artifact link to."),
906
906
  project: z.string().describe("The name or ID of the Azure DevOps project."),
907
907
  // Option 1: Provide full URI directly
908
908
  artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored."),
@@ -911,8 +911,8 @@ function configureWorkItemTools(server, tokenProvider, connectionProvider, userA
911
911
  repositoryId: z.string().optional().describe("The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."),
912
912
  branchName: z.string().optional().describe("The branch name (e.g., 'main'). Required when linkType is 'Branch'."),
913
913
  commitId: z.string().optional().describe("The commit SHA hash. Required when linkType is 'Fixed in Commit'."),
914
- pullRequestId: z.number().optional().describe("The pull request ID. Required when linkType is 'Pull Request'."),
915
- buildId: z.number().optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."),
914
+ pullRequestId: z.coerce.number().min(1).optional().describe("The pull request ID. Required when linkType is 'Pull Request'."),
915
+ buildId: z.coerce.number().min(1).optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."),
916
916
  linkType: z
917
917
  .enum([
918
918
  "Branch",
@@ -99,8 +99,8 @@ function configureWorkTools(server, _, connectionProvider) {
99
99
  });
100
100
  server.tool(WORK_TOOLS.list_iterations, "List all iterations in a specified Azure DevOps project. If a project is not specified, you will be prompted to select one.", {
101
101
  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."),
102
- depth: z.number().default(2).describe("Depth of children to fetch."),
103
- excludedIds: z.array(z.number()).optional().describe("An optional array of iteration IDs, and thier children, that should not be returned."),
102
+ depth: z.coerce.number().default(2).describe("Depth of children to fetch."),
103
+ excludedIds: z.array(z.coerce.number().min(1)).optional().describe("An optional array of iteration IDs, and thier children, that should not be returned."),
104
104
  }, async ({ project, depth, excludedIds: ids }) => {
105
105
  try {
106
106
  const connection = await connectionProvider();
package/dist/utils.js CHANGED
@@ -67,3 +67,18 @@ export function encodeFormattedValue(value, format) {
67
67
  const result = value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
68
68
  return result;
69
69
  }
70
+ /**
71
+ * Convert a Node.js ReadableStream to a string.
72
+ * Shared utility for consistent stream handling across tools.
73
+ */
74
+ export function streamToString(stream) {
75
+ return new Promise((resolve, reject) => {
76
+ let data = "";
77
+ stream.setEncoding("utf8");
78
+ stream.on("data", (chunk) => {
79
+ data += chunk;
80
+ });
81
+ stream.on("error", reject);
82
+ stream.on("end", () => resolve(data));
83
+ });
84
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "2.5.0-nightly.20260323";
1
+ export const packageVersion = "2.5.0-nightly.20260324";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-devops/mcp",
3
- "version": "2.5.0-nightly.20260323",
3
+ "version": "2.5.0-nightly.20260324",
4
4
  "mcpName": "microsoft.com/azure-devops",
5
5
  "description": "MCP server for interacting with Azure DevOps",
6
6
  "license": "MIT",