devops-mcp 1.0.0__py3-none-any.whl

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,1038 @@
1
+ """Pull request tools for Azure DevOps MCP."""
2
+
3
+ import json
4
+ import logging
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+ from mcp.server.fastmcp import Context
9
+
10
+ from devops_mcp._app import mcp, write_tool
11
+ from devops_mcp.client import (
12
+ AppContext,
13
+ build_headers,
14
+ build_url,
15
+ extract_error_message,
16
+ finalize_response,
17
+ request_with_retry,
18
+ resolve_org,
19
+ resolve_project,
20
+ )
21
+ from devops_mcp.models import (
22
+ AddPullRequestCommentInput,
23
+ CreatePullRequestInput,
24
+ CreatePullRequestThreadInput,
25
+ GetPullRequestChangesInput,
26
+ GetPullRequestInput,
27
+ GetPullRequestThreadInput,
28
+ LinkWorkItemsToPullRequestInput,
29
+ ListPullRequestIterationsInput,
30
+ ListPullRequestsInput,
31
+ ListPullRequestThreadsInput,
32
+ SetPullRequestThreadStatusInput,
33
+ TagPullRequestInput,
34
+ UpdatePullRequestCommentInput,
35
+ UpdatePullRequestInput,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _PR_API_VERSION = "7.2-preview.2"
41
+ _WIT_API_VERSION = "7.2-preview.3"
42
+ _THREAD_API_VERSION = "7.1"
43
+
44
+
45
+ def _build_pull_request_artifact_uri(
46
+ project_id: str, repository_id: str, pull_request_id: int
47
+ ) -> str:
48
+ """Build the Azure DevOps artifact URI for a pull request work item link."""
49
+ artifact_key = quote(f"{project_id}/{repository_id}/{pull_request_id}", safe="")
50
+ return f"vstfs:///Git/PullRequestId/{artifact_key}"
51
+
52
+
53
+ @mcp.tool(
54
+ name="devops_get_pull_request",
55
+ annotations={
56
+ "title": "Get Pull Request",
57
+ "readOnlyHint": True,
58
+ "destructiveHint": False,
59
+ "idempotentHint": True,
60
+ "openWorldHint": True,
61
+ },
62
+ )
63
+ async def devops_get_pull_request(params: GetPullRequestInput, ctx: Context) -> str:
64
+ """Get details of a specific Azure DevOps pull request.
65
+
66
+ Returns the full pull request object including ID, title, description,
67
+ status, source and target branches, created-by identity, reviewers,
68
+ merge status, completion options, and optional commits and work item refs.
69
+ """
70
+ app_ctx: AppContext = ctx.request_context.lifespan_context
71
+ try:
72
+ organization = resolve_org(app_ctx, params.organization)
73
+ project = resolve_project(app_ctx, params.project)
74
+ url = build_url(
75
+ organization,
76
+ project,
77
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}",
78
+ )
79
+ query_params: dict = {"api-version": _PR_API_VERSION}
80
+ if params.include_commits:
81
+ query_params["includeCommits"] = "true"
82
+ if params.include_work_item_refs:
83
+ query_params["includeWorkItemRefs"] = "true"
84
+
85
+ response = await request_with_retry(
86
+ app_ctx.http_client,
87
+ "GET",
88
+ url,
89
+ headers=await build_headers(app_ctx),
90
+ params=query_params,
91
+ )
92
+ response.raise_for_status()
93
+ return finalize_response(response.json())
94
+
95
+ except ValueError as e:
96
+ return finalize_response({"error": True, "message": str(e)})
97
+ except httpx.HTTPStatusError as e:
98
+ msg = extract_error_message(e.response)
99
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
100
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
101
+ except Exception as e:
102
+ logger.exception("Unexpected error in devops_get_pull_request")
103
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
104
+
105
+
106
+ @mcp.tool(
107
+ name="devops_list_pull_requests",
108
+ annotations={
109
+ "title": "List Pull Requests",
110
+ "readOnlyHint": True,
111
+ "destructiveHint": False,
112
+ "idempotentHint": True,
113
+ "openWorldHint": True,
114
+ },
115
+ )
116
+ async def devops_list_pull_requests(params: ListPullRequestsInput, ctx: Context) -> str:
117
+ """List pull requests in an Azure DevOps Git repository.
118
+
119
+ Returns a list of pull requests matching the given filters. By default
120
+ returns active pull requests. Supports filtering by status, source/target
121
+ branch, creator, reviewer, labels, and title substring. Use skip and top
122
+ for pagination.
123
+ """
124
+ app_ctx: AppContext = ctx.request_context.lifespan_context
125
+ try:
126
+ organization = resolve_org(app_ctx, params.organization)
127
+ project = resolve_project(app_ctx, params.project)
128
+ url = build_url(
129
+ organization,
130
+ project,
131
+ f"git/repositories/{params.repository_id}/pullrequests",
132
+ )
133
+
134
+ # Build scalar search criteria params
135
+ query_params: list[tuple[str, str]] = [("api-version", _PR_API_VERSION)]
136
+ if params.status is not None:
137
+ query_params.append(("searchCriteria.status", params.status))
138
+ if params.source_ref_name is not None:
139
+ query_params.append(("searchCriteria.sourceRefName", params.source_ref_name))
140
+ if params.target_ref_name is not None:
141
+ query_params.append(("searchCriteria.targetRefName", params.target_ref_name))
142
+ if params.creator_id is not None:
143
+ query_params.append(("searchCriteria.creatorId", params.creator_id))
144
+ if params.reviewer_id is not None:
145
+ query_params.append(("searchCriteria.reviewerId", params.reviewer_id))
146
+ if params.title is not None:
147
+ query_params.append(("searchCriteria.title", params.title))
148
+ if params.top is not None:
149
+ query_params.append(("$top", str(params.top)))
150
+ if params.skip is not None:
151
+ query_params.append(("$skip", str(params.skip)))
152
+ # Labels require repeated query parameters
153
+ if params.labels:
154
+ for label in params.labels:
155
+ query_params.append(("searchCriteria.labels", label))
156
+
157
+ response = await request_with_retry(
158
+ app_ctx.http_client,
159
+ "GET",
160
+ url,
161
+ headers=await build_headers(app_ctx),
162
+ params=query_params,
163
+ )
164
+ response.raise_for_status()
165
+ data = response.json()
166
+ pull_requests = data.get("value", [])
167
+ return finalize_response({
168
+ "pullRequests": pull_requests,
169
+ "count": data.get("count", len(pull_requests)),
170
+ "has_more": params.top is not None and len(pull_requests) >= params.top,
171
+ })
172
+
173
+ except ValueError as e:
174
+ return finalize_response({"error": True, "message": str(e)})
175
+ except httpx.HTTPStatusError as e:
176
+ msg = extract_error_message(e.response)
177
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
178
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
179
+ except Exception as e:
180
+ logger.exception("Unexpected error in devops_list_pull_requests")
181
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
182
+
183
+
184
+ async def _link_work_items(
185
+ app_ctx: AppContext,
186
+ organization: str,
187
+ project: str,
188
+ repository_id: str,
189
+ pull_request_id: int,
190
+ work_item_ids: list[int],
191
+ read_headers: dict,
192
+ ) -> None:
193
+ """Link work items to a pull request via ArtifactLink relations on the work item side.
194
+
195
+ Fetches repository details to obtain the project ID needed to build the
196
+ artifact URI, then for each work item GETs its current relations and
197
+ PATCHes an ArtifactLink add operation only when the link is not already
198
+ present. Uses the work-item JSON-Patch API (_WIT_API_VERSION).
199
+
200
+ Raises httpx.HTTPStatusError on any non-2xx response; callers are
201
+ responsible for catching it within their ordered error handlers.
202
+ """
203
+ repo_url = build_url(organization, project, f"git/repositories/{repository_id}")
204
+ repo_response = await request_with_retry(
205
+ app_ctx.http_client,
206
+ "GET",
207
+ repo_url,
208
+ headers=read_headers,
209
+ params={"api-version": "7.1"},
210
+ )
211
+ repo_response.raise_for_status()
212
+ repository = repo_response.json()
213
+
214
+ resolved_repository_id = repository["id"]
215
+ project_id = repository["project"]["id"]
216
+ artifact_uri = _build_pull_request_artifact_uri(project_id, resolved_repository_id, pull_request_id)
217
+
218
+ patch_headers = await build_headers(
219
+ app_ctx,
220
+ extra={"Content-Type": "application/json-patch+json"},
221
+ )
222
+
223
+ for work_item_id in work_item_ids:
224
+ work_item_url = build_url(organization, project, f"wit/workitems/{work_item_id}")
225
+ work_item_response = await request_with_retry(
226
+ app_ctx.http_client,
227
+ "GET",
228
+ work_item_url,
229
+ headers=read_headers,
230
+ params={"api-version": _WIT_API_VERSION, "$expand": "relations"},
231
+ )
232
+ work_item_response.raise_for_status()
233
+ work_item = work_item_response.json()
234
+
235
+ relations = work_item.get("relations", [])
236
+ already_linked = any(
237
+ relation.get("rel") == "ArtifactLink" and relation.get("url") == artifact_uri
238
+ for relation in relations
239
+ )
240
+ if already_linked:
241
+ continue
242
+
243
+ patch_ops = [
244
+ {
245
+ "op": "add",
246
+ "path": "/relations/-",
247
+ "value": {
248
+ "rel": "ArtifactLink",
249
+ "url": artifact_uri,
250
+ "attributes": {"name": "Pull Request"},
251
+ },
252
+ }
253
+ ]
254
+ patch_response = await request_with_retry(
255
+ app_ctx.http_client,
256
+ "PATCH",
257
+ work_item_url,
258
+ headers=patch_headers,
259
+ params={"api-version": _WIT_API_VERSION},
260
+ content=json.dumps(patch_ops).encode(),
261
+ )
262
+ patch_response.raise_for_status()
263
+
264
+
265
+ @write_tool(
266
+ name="devops_create_pull_request",
267
+ annotations={
268
+ "title": "Create Pull Request",
269
+ "readOnlyHint": False,
270
+ "destructiveHint": False,
271
+ "idempotentHint": False,
272
+ "openWorldHint": True,
273
+ },
274
+ )
275
+ async def devops_create_pull_request(params: CreatePullRequestInput, ctx: Context) -> str:
276
+ """Create a new pull request in an Azure DevOps Git repository.
277
+
278
+ Creates a PR from source_ref_name into target_ref_name. Optionally sets
279
+ a description, draft status, reviewers, labels, and work item associations.
280
+ Completion options (delete source branch, merge strategy) can also be set
281
+ at creation time.
282
+
283
+ When work_item_ids is supplied, each work item is linked to the newly
284
+ created PR by adding an ArtifactLink relation on the work item side (the
285
+ same mechanism used by devops_link_work_items_to_pull_request). Azure
286
+ DevOps does not honour workItemRefs on the PR create/PATCH API, so that
287
+ field is never set here. Returns the newly created pull request object.
288
+ """
289
+ app_ctx: AppContext = ctx.request_context.lifespan_context
290
+ try:
291
+ organization = resolve_org(app_ctx, params.organization)
292
+ project = resolve_project(app_ctx, params.project)
293
+ url = build_url(
294
+ organization,
295
+ project,
296
+ f"git/repositories/{params.repository_id}/pullrequests",
297
+ )
298
+
299
+ body: dict = {
300
+ "sourceRefName": params.source_ref_name,
301
+ "targetRefName": params.target_ref_name,
302
+ "title": params.title,
303
+ "isDraft": params.is_draft,
304
+ }
305
+ if params.description is not None:
306
+ body["description"] = params.description
307
+ if params.reviewers:
308
+ body["reviewers"] = [{"id": uid} for uid in params.reviewers]
309
+ if params.labels:
310
+ body["labels"] = [{"name": name} for name in params.labels]
311
+
312
+ completion_options: dict = {}
313
+ if params.delete_source_branch:
314
+ completion_options["deleteSourceBranch"] = True
315
+ if params.merge_strategy is not None:
316
+ completion_options["mergeStrategy"] = params.merge_strategy
317
+ if completion_options:
318
+ body["completionOptions"] = completion_options
319
+
320
+ response = await request_with_retry(
321
+ app_ctx.http_client,
322
+ "POST",
323
+ url,
324
+ headers=await build_headers(app_ctx, include_content_type=True),
325
+ params={"api-version": _PR_API_VERSION},
326
+ json=body,
327
+ )
328
+ response.raise_for_status()
329
+ created_pr = response.json()
330
+
331
+ if params.work_item_ids:
332
+ read_headers = await build_headers(app_ctx)
333
+ await _link_work_items(
334
+ app_ctx,
335
+ organization,
336
+ project,
337
+ params.repository_id,
338
+ created_pr["pullRequestId"],
339
+ params.work_item_ids,
340
+ read_headers,
341
+ )
342
+
343
+ return finalize_response(created_pr)
344
+
345
+ except ValueError as e:
346
+ return finalize_response({"error": True, "message": str(e)})
347
+ except httpx.HTTPStatusError as e:
348
+ msg = extract_error_message(e.response)
349
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
350
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
351
+ except Exception as e:
352
+ logger.exception("Unexpected error in devops_create_pull_request")
353
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
354
+
355
+
356
+ @write_tool(
357
+ name="devops_update_pull_request",
358
+ annotations={
359
+ "title": "Update Pull Request",
360
+ "readOnlyHint": False,
361
+ "destructiveHint": False,
362
+ "idempotentHint": True,
363
+ "openWorldHint": True,
364
+ },
365
+ )
366
+ async def devops_update_pull_request(params: UpdatePullRequestInput, ctx: Context) -> str:
367
+ """Update an existing Azure DevOps pull request.
368
+
369
+ Supply only the fields you want to change. Supports updating title,
370
+ description, status (active/abandoned/completed), draft state, target
371
+ branch, auto-complete, and completion options (delete source branch,
372
+ merge strategy, merge commit message, work item transitions). Returns
373
+ the updated pull request object.
374
+ """
375
+ app_ctx: AppContext = ctx.request_context.lifespan_context
376
+ try:
377
+ organization = resolve_org(app_ctx, params.organization)
378
+ project = resolve_project(app_ctx, params.project)
379
+ url = build_url(
380
+ organization,
381
+ project,
382
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}",
383
+ )
384
+
385
+ body: dict = {}
386
+ if params.title is not None:
387
+ body["title"] = params.title
388
+ if params.description is not None:
389
+ body["description"] = params.description
390
+ if params.status is not None:
391
+ body["status"] = params.status
392
+ if params.is_draft is not None:
393
+ body["isDraft"] = params.is_draft
394
+ if params.target_ref_name is not None:
395
+ body["targetRefName"] = params.target_ref_name
396
+ if params.auto_complete_identity_id is not None:
397
+ body["autoCompleteSetBy"] = {"id": params.auto_complete_identity_id}
398
+
399
+ completion_options: dict = {}
400
+ if params.delete_source_branch is not None:
401
+ completion_options["deleteSourceBranch"] = params.delete_source_branch
402
+ if params.merge_strategy is not None:
403
+ completion_options["mergeStrategy"] = params.merge_strategy
404
+ if params.merge_commit_message is not None:
405
+ completion_options["mergeCommitMessage"] = params.merge_commit_message
406
+ if params.transition_work_items is not None:
407
+ completion_options["transitionWorkItems"] = params.transition_work_items
408
+ if completion_options:
409
+ body["completionOptions"] = completion_options
410
+
411
+ response = await request_with_retry(
412
+ app_ctx.http_client,
413
+ "PATCH",
414
+ url,
415
+ headers=await build_headers(app_ctx, include_content_type=True),
416
+ params={"api-version": _PR_API_VERSION},
417
+ json=body,
418
+ )
419
+ response.raise_for_status()
420
+ return finalize_response(response.json())
421
+
422
+ except ValueError as e:
423
+ return finalize_response({"error": True, "message": str(e)})
424
+ except httpx.HTTPStatusError as e:
425
+ msg = extract_error_message(e.response)
426
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
427
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
428
+ except Exception as e:
429
+ logger.exception("Unexpected error in devops_update_pull_request")
430
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
431
+
432
+
433
+ @write_tool(
434
+ name="devops_tag_pull_request",
435
+ annotations={
436
+ "title": "Tag or Label Pull Request",
437
+ "readOnlyHint": False,
438
+ "destructiveHint": False,
439
+ "idempotentHint": False,
440
+ "openWorldHint": True,
441
+ },
442
+ )
443
+ async def devops_tag_pull_request(params: TagPullRequestInput, ctx: Context) -> str:
444
+ """Add labels or tags to an Azure DevOps pull request (PR).
445
+
446
+ Use this tool when you want to tag, label, or categorize a pull request.
447
+ It adds each specified label using the dedicated labels endpoint. Labels are
448
+ created automatically if they do not already exist in the project. Returns
449
+ a list of the label objects that were created or applied. This operation is
450
+ additive — existing labels on the PR are not removed.
451
+ """
452
+ app_ctx: AppContext = ctx.request_context.lifespan_context
453
+ try:
454
+ organization = resolve_org(app_ctx, params.organization)
455
+ project = resolve_project(app_ctx, params.project)
456
+ base_url = build_url(
457
+ organization,
458
+ project,
459
+ f"git/repositories/{params.repository_id}/pullRequests/{params.pull_request_id}/labels",
460
+ )
461
+
462
+ results = []
463
+ headers = await build_headers(app_ctx, include_content_type=True)
464
+ for label_name in params.labels:
465
+ response = await request_with_retry(
466
+ app_ctx.http_client,
467
+ "POST",
468
+ base_url,
469
+ headers=headers,
470
+ params={"api-version": _PR_API_VERSION},
471
+ json={"name": label_name},
472
+ )
473
+ response.raise_for_status()
474
+ results.append(response.json())
475
+
476
+ return finalize_response({"labels": results, "count": len(results)})
477
+
478
+ except ValueError as e:
479
+ return finalize_response({"error": True, "message": str(e)})
480
+ except httpx.HTTPStatusError as e:
481
+ msg = extract_error_message(e.response)
482
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
483
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
484
+ except Exception as e:
485
+ logger.exception("Unexpected error in devops_tag_pull_request")
486
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
487
+
488
+
489
+ @write_tool(
490
+ name="devops_link_work_items_to_pull_request",
491
+ annotations={
492
+ "title": "Link Work Items or Boards Items to Pull Request",
493
+ "readOnlyHint": False,
494
+ "destructiveHint": False,
495
+ "idempotentHint": True,
496
+ "openWorldHint": True,
497
+ },
498
+ )
499
+ async def devops_link_work_items_to_pull_request(
500
+ params: LinkWorkItemsToPullRequestInput, ctx: Context
501
+ ) -> str:
502
+ """Link Azure Boards work items to an existing Azure DevOps pull request.
503
+
504
+ Use this tool when you want to link a work item, board item, story, bug,
505
+ task, or backlog item to a PR. It links one or more work items to a pull
506
+ request by adding ArtifactLink relations on the work item side. Azure
507
+ DevOps does not support updating workItemRefs via the pull request PATCH
508
+ API. For the most reliable work item linking, prefer supplying
509
+ work_item_ids when calling devops_create_pull_request. Returns the updated
510
+ pull request object with the workItemRefs included.
511
+ """
512
+ app_ctx: AppContext = ctx.request_context.lifespan_context
513
+ try:
514
+ organization = resolve_org(app_ctx, params.organization)
515
+ project = resolve_project(app_ctx, params.project)
516
+ pr_url = build_url(
517
+ organization,
518
+ project,
519
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}",
520
+ )
521
+
522
+ read_headers = await build_headers(app_ctx)
523
+ await _link_work_items(
524
+ app_ctx,
525
+ organization,
526
+ project,
527
+ params.repository_id,
528
+ params.pull_request_id,
529
+ params.work_item_ids,
530
+ read_headers,
531
+ )
532
+
533
+ # Return the updated PR with work item refs
534
+ pr_response = await request_with_retry(
535
+ app_ctx.http_client,
536
+ "GET",
537
+ pr_url,
538
+ headers=read_headers,
539
+ params={
540
+ "api-version": _PR_API_VERSION,
541
+ "includeWorkItemRefs": "true",
542
+ },
543
+ )
544
+ pr_response.raise_for_status()
545
+ return finalize_response(pr_response.json())
546
+
547
+ except ValueError as e:
548
+ return finalize_response({"error": True, "message": str(e)})
549
+ except httpx.HTTPStatusError as e:
550
+ msg = extract_error_message(e.response)
551
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
552
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
553
+ except Exception as e:
554
+ logger.exception("Unexpected error in devops_link_work_items_to_pull_request")
555
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
556
+
557
+
558
+ @mcp.tool(
559
+ name="devops_list_pull_request_threads",
560
+ annotations={
561
+ "title": "List Pull Request Threads",
562
+ "readOnlyHint": True,
563
+ "destructiveHint": False,
564
+ "idempotentHint": True,
565
+ "openWorldHint": True,
566
+ },
567
+ )
568
+ async def devops_list_pull_request_threads(
569
+ params: ListPullRequestThreadsInput, ctx: Context
570
+ ) -> str:
571
+ """List all comment threads on an Azure DevOps pull request.
572
+
573
+ Returns a JSON object with keys:
574
+ - threads: array of thread objects, each containing id, status, comments,
575
+ threadContext (file path and line/offset when present), identities, and
576
+ timestamps.
577
+ - count: total number of threads returned.
578
+ """
579
+ app_ctx: AppContext = ctx.request_context.lifespan_context
580
+ try:
581
+ organization = resolve_org(app_ctx, params.organization)
582
+ project = resolve_project(app_ctx, params.project)
583
+ url = build_url(
584
+ organization,
585
+ project,
586
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads",
587
+ )
588
+
589
+ response = await request_with_retry(
590
+ app_ctx.http_client,
591
+ "GET",
592
+ url,
593
+ headers=await build_headers(app_ctx),
594
+ params={"api-version": _THREAD_API_VERSION},
595
+ )
596
+ response.raise_for_status()
597
+ data = response.json()
598
+ threads = data.get("value", [])
599
+ return finalize_response({"threads": threads, "count": data.get("count", len(threads))})
600
+
601
+ except ValueError as e:
602
+ return finalize_response({"error": True, "message": str(e)})
603
+ except httpx.HTTPStatusError as e:
604
+ msg = extract_error_message(e.response)
605
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
606
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
607
+ except Exception as e:
608
+ logger.exception("Unexpected error in devops_list_pull_request_threads")
609
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
610
+
611
+
612
+ @mcp.tool(
613
+ name="devops_get_pull_request_thread",
614
+ annotations={
615
+ "title": "Get Pull Request Thread",
616
+ "readOnlyHint": True,
617
+ "destructiveHint": False,
618
+ "idempotentHint": True,
619
+ "openWorldHint": True,
620
+ },
621
+ )
622
+ async def devops_get_pull_request_thread(
623
+ params: GetPullRequestThreadInput, ctx: Context
624
+ ) -> str:
625
+ """Get a single comment thread from an Azure DevOps pull request.
626
+
627
+ Returns the full thread object including id, status, comments (with content,
628
+ author, and timestamps), threadContext (file path and line/offset for inline
629
+ threads), and publishedDate / lastUpdatedDate.
630
+ """
631
+ app_ctx: AppContext = ctx.request_context.lifespan_context
632
+ try:
633
+ organization = resolve_org(app_ctx, params.organization)
634
+ project = resolve_project(app_ctx, params.project)
635
+ url = build_url(
636
+ organization,
637
+ project,
638
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}",
639
+ )
640
+
641
+ response = await request_with_retry(
642
+ app_ctx.http_client,
643
+ "GET",
644
+ url,
645
+ headers=await build_headers(app_ctx),
646
+ params={"api-version": _THREAD_API_VERSION},
647
+ )
648
+ response.raise_for_status()
649
+ return finalize_response(response.json())
650
+
651
+ except ValueError as e:
652
+ return finalize_response({"error": True, "message": str(e)})
653
+ except httpx.HTTPStatusError as e:
654
+ msg = extract_error_message(e.response)
655
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
656
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
657
+ except Exception as e:
658
+ logger.exception("Unexpected error in devops_get_pull_request_thread")
659
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
660
+
661
+
662
+ @write_tool(
663
+ name="devops_create_pull_request_thread",
664
+ annotations={
665
+ "title": "Create Pull Request Thread",
666
+ "readOnlyHint": False,
667
+ "destructiveHint": False,
668
+ "idempotentHint": False,
669
+ "openWorldHint": True,
670
+ },
671
+ )
672
+ async def devops_create_pull_request_thread(
673
+ params: CreatePullRequestThreadInput, ctx: Context
674
+ ) -> str:
675
+ """Create a new comment thread on an Azure DevOps pull request.
676
+
677
+ When file_path is supplied, creates an inline thread anchored to the
678
+ specified file and line range (code comment). When file_path is omitted,
679
+ creates a general PR-level comment thread.
680
+
681
+ Returns the newly created thread object including its id, status, comments,
682
+ and threadContext when applicable.
683
+ """
684
+ app_ctx: AppContext = ctx.request_context.lifespan_context
685
+ try:
686
+ organization = resolve_org(app_ctx, params.organization)
687
+ project = resolve_project(app_ctx, params.project)
688
+ url = build_url(
689
+ organization,
690
+ project,
691
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads",
692
+ )
693
+
694
+ body: dict = {
695
+ "comments": [
696
+ {
697
+ "parentCommentId": 0,
698
+ "content": params.content,
699
+ "commentType": "text",
700
+ }
701
+ ],
702
+ "status": params.status,
703
+ }
704
+
705
+ if params.file_path is not None:
706
+ body["threadContext"] = {
707
+ "filePath": params.file_path,
708
+ "rightFileStart": {
709
+ "line": params.right_file_start_line,
710
+ "offset": params.right_file_start_offset,
711
+ },
712
+ "rightFileEnd": {
713
+ "line": params.right_file_end_line,
714
+ "offset": params.right_file_end_offset,
715
+ },
716
+ }
717
+
718
+ response = await request_with_retry(
719
+ app_ctx.http_client,
720
+ "POST",
721
+ url,
722
+ headers=await build_headers(app_ctx, include_content_type=True),
723
+ params={"api-version": _THREAD_API_VERSION},
724
+ json=body,
725
+ )
726
+ response.raise_for_status()
727
+ return finalize_response(response.json())
728
+
729
+ except ValueError as e:
730
+ return finalize_response({"error": True, "message": str(e)})
731
+ except httpx.HTTPStatusError as e:
732
+ msg = extract_error_message(e.response)
733
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
734
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
735
+ except Exception as e:
736
+ logger.exception("Unexpected error in devops_create_pull_request_thread")
737
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
738
+
739
+
740
+ @write_tool(
741
+ name="devops_set_pull_request_thread_status",
742
+ annotations={
743
+ "title": "Set Pull Request Thread Status",
744
+ "readOnlyHint": False,
745
+ "destructiveHint": False,
746
+ "idempotentHint": True,
747
+ "openWorldHint": True,
748
+ },
749
+ )
750
+ async def devops_set_pull_request_thread_status(
751
+ params: SetPullRequestThreadStatusInput, ctx: Context
752
+ ) -> str:
753
+ """Update the status of a comment thread on an Azure DevOps pull request.
754
+
755
+ Valid status values: 'active', 'fixed', 'wontFix', 'closed', 'byDesign',
756
+ 'pending'. Returns the updated thread object including id, status, comments,
757
+ and threadContext.
758
+ """
759
+ app_ctx: AppContext = ctx.request_context.lifespan_context
760
+ try:
761
+ organization = resolve_org(app_ctx, params.organization)
762
+ project = resolve_project(app_ctx, params.project)
763
+ url = build_url(
764
+ organization,
765
+ project,
766
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}",
767
+ )
768
+
769
+ response = await request_with_retry(
770
+ app_ctx.http_client,
771
+ "PATCH",
772
+ url,
773
+ headers=await build_headers(app_ctx, include_content_type=True),
774
+ params={"api-version": _THREAD_API_VERSION},
775
+ json={"status": params.status},
776
+ )
777
+ response.raise_for_status()
778
+ return finalize_response(response.json())
779
+
780
+ except ValueError as e:
781
+ return finalize_response({"error": True, "message": str(e)})
782
+ except httpx.HTTPStatusError as e:
783
+ msg = extract_error_message(e.response)
784
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
785
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
786
+ except Exception as e:
787
+ logger.exception("Unexpected error in devops_set_pull_request_thread_status")
788
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
789
+
790
+
791
+ @write_tool(
792
+ name="devops_add_pull_request_comment",
793
+ annotations={
794
+ "title": "Add Pull Request Comment",
795
+ "readOnlyHint": False,
796
+ "destructiveHint": False,
797
+ "idempotentHint": False,
798
+ "openWorldHint": True,
799
+ },
800
+ )
801
+ async def devops_add_pull_request_comment(
802
+ params: AddPullRequestCommentInput, ctx: Context
803
+ ) -> str:
804
+ """Add a reply comment to an existing thread on an Azure DevOps pull request.
805
+
806
+ Use parent_comment_id=0 (default) to add a top-level reply on the thread.
807
+ Supply a non-zero parent_comment_id to nest the reply under a specific
808
+ comment within the thread.
809
+
810
+ Returns the newly created comment object including id, content, author,
811
+ commentType, and publishedDate.
812
+ """
813
+ app_ctx: AppContext = ctx.request_context.lifespan_context
814
+ try:
815
+ organization = resolve_org(app_ctx, params.organization)
816
+ project = resolve_project(app_ctx, params.project)
817
+ url = build_url(
818
+ organization,
819
+ project,
820
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}/comments",
821
+ )
822
+
823
+ body: dict = {
824
+ "parentCommentId": params.parent_comment_id,
825
+ "content": params.content,
826
+ "commentType": "text",
827
+ }
828
+
829
+ response = await request_with_retry(
830
+ app_ctx.http_client,
831
+ "POST",
832
+ url,
833
+ headers=await build_headers(app_ctx, include_content_type=True),
834
+ params={"api-version": _THREAD_API_VERSION},
835
+ json=body,
836
+ )
837
+ response.raise_for_status()
838
+ return finalize_response(response.json())
839
+
840
+ except ValueError as e:
841
+ return finalize_response({"error": True, "message": str(e)})
842
+ except httpx.HTTPStatusError as e:
843
+ msg = extract_error_message(e.response)
844
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
845
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
846
+ except Exception as e:
847
+ logger.exception("Unexpected error in devops_add_pull_request_comment")
848
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
849
+
850
+
851
+ @write_tool(
852
+ name="devops_update_pull_request_comment",
853
+ annotations={
854
+ "title": "Update Pull Request Comment",
855
+ "readOnlyHint": False,
856
+ "destructiveHint": False,
857
+ "idempotentHint": True,
858
+ "openWorldHint": True,
859
+ },
860
+ )
861
+ async def devops_update_pull_request_comment(
862
+ params: UpdatePullRequestCommentInput, ctx: Context
863
+ ) -> str:
864
+ """Update the content of an existing comment in an Azure DevOps pull request thread.
865
+
866
+ Returns the updated comment object including id, content, author,
867
+ commentType, lastUpdatedDate, and isDeleted flag.
868
+ """
869
+ app_ctx: AppContext = ctx.request_context.lifespan_context
870
+ try:
871
+ organization = resolve_org(app_ctx, params.organization)
872
+ project = resolve_project(app_ctx, params.project)
873
+ url = build_url(
874
+ organization,
875
+ project,
876
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}/comments/{params.comment_id}",
877
+ )
878
+
879
+ response = await request_with_retry(
880
+ app_ctx.http_client,
881
+ "PATCH",
882
+ url,
883
+ headers=await build_headers(app_ctx, include_content_type=True),
884
+ params={"api-version": _THREAD_API_VERSION},
885
+ json={"content": params.content},
886
+ )
887
+ response.raise_for_status()
888
+ return finalize_response(response.json())
889
+
890
+ except ValueError as e:
891
+ return finalize_response({"error": True, "message": str(e)})
892
+ except httpx.HTTPStatusError as e:
893
+ msg = extract_error_message(e.response)
894
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
895
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
896
+ except Exception as e:
897
+ logger.exception("Unexpected error in devops_update_pull_request_comment")
898
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
899
+
900
+
901
+ @mcp.tool(
902
+ name="devops_list_pull_request_iterations",
903
+ annotations={
904
+ "title": "List Pull Request Iterations",
905
+ "readOnlyHint": True,
906
+ "destructiveHint": False,
907
+ "idempotentHint": True,
908
+ "openWorldHint": True,
909
+ },
910
+ )
911
+ async def devops_list_pull_request_iterations(
912
+ params: ListPullRequestIterationsInput, ctx: Context
913
+ ) -> str:
914
+ """List the push iterations for an Azure DevOps pull request.
915
+
916
+ Each iteration represents a round of changes pushed to the source branch
917
+ after the PR was created. Useful for understanding the history of updates
918
+ and for targeting a specific iteration when calling
919
+ devops_get_pull_request_changes.
920
+
921
+ Returns a JSON object with keys:
922
+ - iterations: array of iteration objects, each containing id, description,
923
+ author, createdDate, sourceRefCommit, targetRefCommit, commonRefCommit,
924
+ and optionally commits when include_commits is True.
925
+ - count: total number of iterations returned.
926
+ """
927
+ app_ctx: AppContext = ctx.request_context.lifespan_context
928
+ try:
929
+ organization = resolve_org(app_ctx, params.organization)
930
+ project = resolve_project(app_ctx, params.project)
931
+ url = build_url(
932
+ organization,
933
+ project,
934
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/iterations",
935
+ )
936
+
937
+ query_params: dict = {"api-version": _THREAD_API_VERSION}
938
+ if params.include_commits:
939
+ query_params["includeCommits"] = "true"
940
+
941
+ response = await request_with_retry(
942
+ app_ctx.http_client,
943
+ "GET",
944
+ url,
945
+ headers=await build_headers(app_ctx),
946
+ params=query_params,
947
+ )
948
+ response.raise_for_status()
949
+ data = response.json()
950
+ iterations = data.get("value", [])
951
+ return finalize_response({
952
+ "iterations": iterations,
953
+ "count": data.get("count", len(iterations)),
954
+ })
955
+
956
+ except ValueError as e:
957
+ return finalize_response({"error": True, "message": str(e)})
958
+ except httpx.HTTPStatusError as e:
959
+ msg = extract_error_message(e.response)
960
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
961
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
962
+ except Exception as e:
963
+ logger.exception("Unexpected error in devops_list_pull_request_iterations")
964
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
965
+
966
+
967
+ @mcp.tool(
968
+ name="devops_get_pull_request_changes",
969
+ annotations={
970
+ "title": "Get Pull Request Changes",
971
+ "readOnlyHint": True,
972
+ "destructiveHint": False,
973
+ "idempotentHint": True,
974
+ "openWorldHint": True,
975
+ },
976
+ )
977
+ async def devops_get_pull_request_changes(
978
+ params: GetPullRequestChangesInput, ctx: Context
979
+ ) -> str:
980
+ """Get the file-change entries for a specific pull request iteration.
981
+
982
+ Returns the list of files added, edited, deleted, or renamed in the given
983
+ iteration. When compare_to is supplied, only the incremental changes between
984
+ that iteration and iteration_id are returned. Use top and skip to paginate
985
+ large change sets.
986
+
987
+ Returns a JSON object with keys:
988
+ - changes: array of change entry objects, each containing changeId,
989
+ item (with path and other metadata), and changeType
990
+ (e.g. 'edit', 'add', 'delete', 'rename').
991
+ - count: number of change entries in this response.
992
+ - nextSkip: present when more entries exist; pass as skip on the next call.
993
+ """
994
+ app_ctx: AppContext = ctx.request_context.lifespan_context
995
+ try:
996
+ organization = resolve_org(app_ctx, params.organization)
997
+ project = resolve_project(app_ctx, params.project)
998
+ url = build_url(
999
+ organization,
1000
+ project,
1001
+ f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/iterations/{params.iteration_id}/changes",
1002
+ )
1003
+
1004
+ query_params: dict = {"api-version": _THREAD_API_VERSION}
1005
+ if params.compare_to is not None:
1006
+ query_params["$compareTo"] = params.compare_to
1007
+ if params.top is not None:
1008
+ query_params["$top"] = params.top
1009
+ if params.skip is not None:
1010
+ query_params["$skip"] = params.skip
1011
+
1012
+ response = await request_with_retry(
1013
+ app_ctx.http_client,
1014
+ "GET",
1015
+ url,
1016
+ headers=await build_headers(app_ctx),
1017
+ params=query_params,
1018
+ )
1019
+ response.raise_for_status()
1020
+ data = response.json()
1021
+ changes = data.get("changeEntries", [])
1022
+ result: dict = {
1023
+ "changes": changes,
1024
+ "count": len(changes),
1025
+ }
1026
+ if "nextSkip" in data:
1027
+ result["nextSkip"] = data["nextSkip"]
1028
+ return finalize_response(result)
1029
+
1030
+ except ValueError as e:
1031
+ return finalize_response({"error": True, "message": str(e)})
1032
+ except httpx.HTTPStatusError as e:
1033
+ msg = extract_error_message(e.response)
1034
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
1035
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
1036
+ except Exception as e:
1037
+ logger.exception("Unexpected error in devops_get_pull_request_changes")
1038
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})