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,182 @@
1
+ """Repository tools for Azure DevOps MCP."""
2
+
3
+ import logging
4
+
5
+ import httpx
6
+ from mcp.server.fastmcp import Context
7
+
8
+ from devops_mcp._app import mcp
9
+ from devops_mcp.client import (
10
+ AppContext,
11
+ build_headers,
12
+ build_params,
13
+ build_url,
14
+ extract_error_message,
15
+ finalize_response,
16
+ paginate_results,
17
+ request_with_retry,
18
+ resolve_org,
19
+ resolve_project,
20
+ )
21
+ from devops_mcp.models import GetRepositoryInput, ListBranchesInput, ListRepositoriesInput
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @mcp.tool(
27
+ name="devops_list_repositories",
28
+ annotations={
29
+ "title": "List Repositories",
30
+ "readOnlyHint": True,
31
+ "destructiveHint": False,
32
+ "idempotentHint": True,
33
+ "openWorldHint": True,
34
+ },
35
+ )
36
+ async def devops_list_repositories(params: ListRepositoriesInput, ctx: Context) -> str:
37
+ """List Git repositories in an Azure DevOps project.
38
+
39
+ Returns repository IDs, names, default branches, HTTPS and SSH clone URLs,
40
+ web URLs, sizes, and project details. Use the repository ID or name with
41
+ devops_get_repository or devops_list_branches.
42
+ """
43
+ app_ctx: AppContext = ctx.request_context.lifespan_context
44
+ try:
45
+ organization = resolve_org(app_ctx, params.organization)
46
+ project = resolve_project(app_ctx, params.project)
47
+ url = build_url(organization, project, "git/repositories")
48
+ query_params = build_params(
49
+ includeLinks="true" if params.include_links else None,
50
+ includeAllUrls="true" if params.include_all_urls else None,
51
+ includeHidden="true" if params.include_hidden else None,
52
+ )
53
+
54
+ response = await request_with_retry(
55
+ app_ctx.http_client,
56
+ "GET",
57
+ url,
58
+ headers=await build_headers(app_ctx),
59
+ params=query_params,
60
+ )
61
+ response.raise_for_status()
62
+ data = response.json()
63
+ repos = data.get("value", [])
64
+ return finalize_response({
65
+ "repositories": repos,
66
+ "count": data.get("count", len(repos)),
67
+ })
68
+
69
+ except ValueError as e:
70
+ return finalize_response({"error": True, "message": str(e)})
71
+ except httpx.HTTPStatusError as e:
72
+ msg = extract_error_message(e.response)
73
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
74
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
75
+ except Exception as e:
76
+ logger.exception("Unexpected error in devops_list_repositories")
77
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
78
+
79
+
80
+ @mcp.tool(
81
+ name="devops_get_repository",
82
+ annotations={
83
+ "title": "Get Repository",
84
+ "readOnlyHint": True,
85
+ "destructiveHint": False,
86
+ "idempotentHint": True,
87
+ "openWorldHint": True,
88
+ },
89
+ )
90
+ async def devops_get_repository(params: GetRepositoryInput, ctx: Context) -> str:
91
+ """Get details of a specific Azure DevOps Git repository.
92
+
93
+ Returns full repository metadata including ID, name, default branch,
94
+ remote URL, SSH URL, web URL, size in bytes, fork status, maintenance
95
+ status, and project information.
96
+ """
97
+ app_ctx: AppContext = ctx.request_context.lifespan_context
98
+ try:
99
+ organization = resolve_org(app_ctx, params.organization)
100
+ project = resolve_project(app_ctx, params.project)
101
+ url = build_url(organization, project, f"git/repositories/{params.repository_id}")
102
+
103
+ response = await request_with_retry(
104
+ app_ctx.http_client,
105
+ "GET",
106
+ url,
107
+ headers=await build_headers(app_ctx),
108
+ params=build_params(),
109
+ )
110
+ response.raise_for_status()
111
+ return finalize_response(response.json())
112
+
113
+ except ValueError as e:
114
+ return finalize_response({"error": True, "message": str(e)})
115
+ except httpx.HTTPStatusError as e:
116
+ msg = extract_error_message(e.response)
117
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
118
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
119
+ except Exception as e:
120
+ logger.exception("Unexpected error in devops_get_repository")
121
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
122
+
123
+
124
+ @mcp.tool(
125
+ name="devops_list_branches",
126
+ annotations={
127
+ "title": "List Branches",
128
+ "readOnlyHint": True,
129
+ "destructiveHint": False,
130
+ "idempotentHint": True,
131
+ "openWorldHint": True,
132
+ },
133
+ )
134
+ async def devops_list_branches(params: ListBranchesInput, ctx: Context) -> str:
135
+ """List branches in an Azure DevOps Git repository.
136
+
137
+ Returns branch names (in full ref format, e.g., refs/heads/main), commit
138
+ SHAs, and creator information. Use filter_contains to narrow results to
139
+ branches whose names contain a given substring.
140
+ """
141
+ app_ctx: AppContext = ctx.request_context.lifespan_context
142
+ try:
143
+ organization = resolve_org(app_ctx, params.organization)
144
+ project = resolve_project(app_ctx, params.project)
145
+ url = build_url(
146
+ organization, project,
147
+ f"git/repositories/{params.repository_id}/refs",
148
+ )
149
+
150
+ effective_top = params.top if params.top is not None else 100
151
+ base_params = build_params(
152
+ filter="heads/",
153
+ filterContains=params.filter_contains,
154
+ **{"$top": effective_top},
155
+ )
156
+
157
+ headers = await build_headers(app_ctx)
158
+ branches, has_more = await paginate_results(
159
+ app_ctx.http_client,
160
+ url,
161
+ headers,
162
+ base_params,
163
+ record_key="value",
164
+ top=effective_top,
165
+ )
166
+
167
+ result: dict = {
168
+ "branches": branches,
169
+ "count": len(branches),
170
+ "has_more": has_more,
171
+ }
172
+ return finalize_response(result)
173
+
174
+ except ValueError as e:
175
+ return finalize_response({"error": True, "message": str(e)})
176
+ except httpx.HTTPStatusError as e:
177
+ msg = extract_error_message(e.response)
178
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
179
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
180
+ except Exception as e:
181
+ logger.exception("Unexpected error in devops_list_branches")
182
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
@@ -0,0 +1,495 @@
1
+ """Work item 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_params,
15
+ build_url,
16
+ extract_error_message,
17
+ finalize_response,
18
+ request_with_retry,
19
+ resolve_org,
20
+ resolve_project,
21
+ )
22
+ from devops_mcp.models import (
23
+ AddWorkItemCommentInput,
24
+ CreateWorkItemInput,
25
+ GetWorkItemInput,
26
+ ListWorkItemsInput,
27
+ QueryWorkItemsInput,
28
+ UpdateWorkItemCommentInput,
29
+ UpdateWorkItemInput,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _WIT_API_VERSION = "7.2-preview.3"
35
+ _WIT_COMMENTS_API_VERSION = "7.0-preview.3"
36
+
37
+
38
+ @mcp.tool(
39
+ name="devops_get_work_item",
40
+ annotations={
41
+ "title": "Get Work Item",
42
+ "readOnlyHint": True,
43
+ "destructiveHint": False,
44
+ "idempotentHint": True,
45
+ "openWorldHint": True,
46
+ },
47
+ )
48
+ async def devops_get_work_item(params: GetWorkItemInput, ctx: Context) -> str:
49
+ """Get details of a specific Azure DevOps work item by ID.
50
+
51
+ Returns work item fields including title, state, type, assigned user,
52
+ area and iteration paths, created/changed dates, description, and tags.
53
+ Use the 'fields' parameter to limit which fields are returned, and
54
+ 'expand' to include relations or links.
55
+ """
56
+ app_ctx: AppContext = ctx.request_context.lifespan_context
57
+ try:
58
+ organization = resolve_org(app_ctx, params.organization)
59
+ project = resolve_project(app_ctx, params.project)
60
+ url = build_url(organization, project, f"wit/workitems/{params.work_item_id}")
61
+ query_params = build_params(
62
+ fields=",".join(params.fields) if params.fields else None,
63
+ **{"$expand": params.expand},
64
+ )
65
+
66
+ response = await request_with_retry(
67
+ app_ctx.http_client,
68
+ "GET",
69
+ url,
70
+ headers=await build_headers(app_ctx),
71
+ params=query_params,
72
+ )
73
+ response.raise_for_status()
74
+ return finalize_response(response.json())
75
+
76
+ except ValueError as e:
77
+ return finalize_response({"error": True, "message": str(e)})
78
+ except httpx.HTTPStatusError as e:
79
+ msg = extract_error_message(e.response)
80
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
81
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
82
+ except Exception as e:
83
+ logger.exception("Unexpected error in devops_get_work_item")
84
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
85
+
86
+
87
+ @mcp.tool(
88
+ name="devops_list_work_items",
89
+ annotations={
90
+ "title": "List Work Items",
91
+ "readOnlyHint": True,
92
+ "destructiveHint": False,
93
+ "idempotentHint": True,
94
+ "openWorldHint": True,
95
+ },
96
+ )
97
+ async def devops_list_work_items(params: ListWorkItemsInput, ctx: Context) -> str:
98
+ """Bulk-fetch Azure DevOps work items by their IDs (max 200 per call).
99
+
100
+ Returns full field values for each work item. Use devops_query_work_items
101
+ to discover work item IDs via a WIQL query, then call this tool to retrieve
102
+ the full details for a specific set of IDs.
103
+ """
104
+ app_ctx: AppContext = ctx.request_context.lifespan_context
105
+ try:
106
+ organization = resolve_org(app_ctx, params.organization)
107
+ project = resolve_project(app_ctx, params.project)
108
+ url = build_url(organization, project, "wit/workitems")
109
+ query_params = build_params(
110
+ ids=",".join(str(i) for i in params.ids),
111
+ fields=",".join(params.fields) if params.fields else None,
112
+ errorPolicy=params.error_policy,
113
+ **{"$expand": params.expand},
114
+ )
115
+
116
+ response = await request_with_retry(
117
+ app_ctx.http_client,
118
+ "GET",
119
+ url,
120
+ headers=await build_headers(app_ctx),
121
+ params=query_params,
122
+ )
123
+ response.raise_for_status()
124
+ data = response.json()
125
+ work_items = data.get("value", [])
126
+ return finalize_response({
127
+ "work_items": work_items,
128
+ "count": data.get("count", len(work_items)),
129
+ })
130
+
131
+ except ValueError as e:
132
+ return finalize_response({"error": True, "message": str(e)})
133
+ except httpx.HTTPStatusError as e:
134
+ msg = extract_error_message(e.response)
135
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
136
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
137
+ except Exception as e:
138
+ logger.exception("Unexpected error in devops_list_work_items")
139
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
140
+
141
+
142
+ @mcp.tool(
143
+ name="devops_query_work_items",
144
+ annotations={
145
+ "title": "Query Work Items",
146
+ "readOnlyHint": True,
147
+ "destructiveHint": False,
148
+ "idempotentHint": False,
149
+ "openWorldHint": True,
150
+ },
151
+ )
152
+ async def devops_query_work_items(params: QueryWorkItemsInput, ctx: Context) -> str:
153
+ """Query Azure DevOps work items using WIQL (Work Item Query Language).
154
+
155
+ Executes a WIQL query and by default auto-fetches full field values for
156
+ all returned work items (batching in groups of 200 as required by the API).
157
+ Set fetch_details=False to return only IDs and URLs from the WIQL result.
158
+
159
+ Common WIQL patterns:
160
+ - All open items: WHERE [System.TeamProject] = @project AND [System.State] <> 'Closed'
161
+ - Open bugs: WHERE [System.WorkItemType] = 'Bug' AND [System.State] <> 'Closed'
162
+ - Assigned to me: WHERE [System.AssignedTo] = @me
163
+ - Recent changes: WHERE [System.ChangedDate] >= @today - 7
164
+
165
+ Note: WIQL only returns IDs; this tool handles the two-step fetch automatically.
166
+ """
167
+ app_ctx: AppContext = ctx.request_context.lifespan_context
168
+ try:
169
+ organization = resolve_org(app_ctx, params.organization)
170
+ project = resolve_project(app_ctx, params.project)
171
+ wiql_url = build_url(organization, project, "wit/wiql")
172
+
173
+ headers = await build_headers(app_ctx, include_content_type=True)
174
+ wiql_response = await request_with_retry(
175
+ app_ctx.http_client,
176
+ "POST",
177
+ wiql_url,
178
+ headers=headers,
179
+ params=build_params(**{"$top": params.top}),
180
+ json={"query": params.wiql},
181
+ )
182
+ wiql_response.raise_for_status()
183
+ wiql_result = wiql_response.json()
184
+
185
+ raw_items = wiql_result.get("workItems", [])
186
+ ids = [item["id"] for item in raw_items]
187
+
188
+ if not ids:
189
+ return finalize_response({
190
+ "work_items": [],
191
+ "count": 0,
192
+ "query_type": wiql_result.get("queryType"),
193
+ "as_of": wiql_result.get("asOf"),
194
+ })
195
+
196
+ if not params.fetch_details:
197
+ return finalize_response({
198
+ "work_item_ids": ids,
199
+ "count": len(ids),
200
+ "query_type": wiql_result.get("queryType"),
201
+ "as_of": wiql_result.get("asOf"),
202
+ })
203
+
204
+ read_headers = await build_headers(app_ctx)
205
+ all_work_items: list[dict] = []
206
+ for i in range(0, len(ids), 200):
207
+ batch = ids[i : i + 200]
208
+ details_url = build_url(organization, project, "wit/workitems")
209
+ details_params = build_params(
210
+ ids=",".join(str(i) for i in batch),
211
+ fields=",".join(params.fields) if params.fields else None,
212
+ errorPolicy="omit",
213
+ )
214
+ details_response = await request_with_retry(
215
+ app_ctx.http_client,
216
+ "GET",
217
+ details_url,
218
+ headers=read_headers,
219
+ params=details_params,
220
+ )
221
+ details_response.raise_for_status()
222
+ all_work_items.extend(details_response.json().get("value", []))
223
+
224
+ return finalize_response({
225
+ "work_items": all_work_items,
226
+ "count": len(all_work_items),
227
+ "query_type": wiql_result.get("queryType"),
228
+ "as_of": wiql_result.get("asOf"),
229
+ })
230
+
231
+ except ValueError as e:
232
+ return finalize_response({"error": True, "message": str(e)})
233
+ except httpx.HTTPStatusError as e:
234
+ msg = extract_error_message(e.response)
235
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
236
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
237
+ except Exception as e:
238
+ logger.exception("Unexpected error in devops_query_work_items")
239
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
240
+
241
+
242
+ @write_tool(
243
+ name="devops_create_work_item",
244
+ annotations={
245
+ "title": "Create Work Item",
246
+ "readOnlyHint": False,
247
+ "destructiveHint": False,
248
+ "idempotentHint": False,
249
+ "openWorldHint": True,
250
+ },
251
+ )
252
+ async def devops_create_work_item(params: CreateWorkItemInput, ctx: Context) -> str:
253
+ """Create a new work item in an Azure DevOps project.
254
+
255
+ Builds a JSON Patch document from the supplied field values and POSTs it
256
+ to the Azure DevOps Work Items API. Returns the newly created work item
257
+ object including its ID, revision, and all fields.
258
+
259
+ Common work item types: Bug, Task, User Story, Feature, Epic, Issue,
260
+ Test Case. The exact set of valid types depends on the project process
261
+ template (Agile, Scrum, CMMI, or custom).
262
+ """
263
+ app_ctx: AppContext = ctx.request_context.lifespan_context
264
+ try:
265
+ organization = resolve_org(app_ctx, params.organization)
266
+ project = resolve_project(app_ctx, params.project)
267
+ url = build_url(organization, project, f"wit/workitems/${params.work_item_type}")
268
+
269
+ patch_ops: list[dict] = [
270
+ {"op": "add", "path": "/fields/System.Title", "value": params.title},
271
+ ]
272
+ if params.description is not None:
273
+ patch_ops.append({"op": "add", "path": "/fields/System.Description", "value": params.description})
274
+ if params.assigned_to is not None:
275
+ patch_ops.append({"op": "add", "path": "/fields/System.AssignedTo", "value": params.assigned_to})
276
+ if params.state is not None:
277
+ patch_ops.append({"op": "add", "path": "/fields/System.State", "value": params.state})
278
+ if params.area_path is not None:
279
+ patch_ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": params.area_path})
280
+ if params.iteration_path is not None:
281
+ patch_ops.append({"op": "add", "path": "/fields/System.IterationPath", "value": params.iteration_path})
282
+ if params.priority is not None:
283
+ patch_ops.append({"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority", "value": params.priority})
284
+ if params.tags is not None:
285
+ patch_ops.append({"op": "add", "path": "/fields/System.Tags", "value": params.tags})
286
+ if params.parent_id is not None:
287
+ parent_url = f"https://dev.azure.com/{quote(organization, safe='')}/_apis/wit/workItems/{params.parent_id}"
288
+ patch_ops.append({
289
+ "op": "add",
290
+ "path": "/relations/-",
291
+ "value": {
292
+ "rel": "System.LinkTypes.Hierarchy-Reverse",
293
+ "url": parent_url,
294
+ "attributes": {"isLocked": False},
295
+ },
296
+ })
297
+ if params.additional_fields:
298
+ for field_name, field_value in params.additional_fields.items():
299
+ patch_ops.append({"op": "add", "path": f"/fields/{field_name}", "value": field_value})
300
+
301
+ response = await request_with_retry(
302
+ app_ctx.http_client,
303
+ "POST",
304
+ url,
305
+ headers=await build_headers(
306
+ app_ctx,
307
+ extra={"Content-Type": "application/json-patch+json"},
308
+ ),
309
+ params={"api-version": _WIT_API_VERSION},
310
+ content=json.dumps(patch_ops).encode(),
311
+ )
312
+ response.raise_for_status()
313
+ return finalize_response(response.json())
314
+
315
+ except ValueError as e:
316
+ return finalize_response({"error": True, "message": str(e)})
317
+ except httpx.HTTPStatusError as e:
318
+ msg = extract_error_message(e.response)
319
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
320
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
321
+ except Exception as e:
322
+ logger.exception("Unexpected error in devops_create_work_item")
323
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
324
+
325
+
326
+ @write_tool(
327
+ name="devops_update_work_item",
328
+ annotations={
329
+ "title": "Update Work Item",
330
+ "readOnlyHint": False,
331
+ "destructiveHint": False,
332
+ "idempotentHint": True,
333
+ "openWorldHint": True,
334
+ },
335
+ )
336
+ async def devops_update_work_item(params: UpdateWorkItemInput, ctx: Context) -> str:
337
+ """Update an existing Azure DevOps work item.
338
+
339
+ Builds a JSON Patch document from only the fields you provide and PATCHes
340
+ the work item. Supply only the fields you want to change — omitted fields
341
+ are left unchanged. Returns the updated work item object.
342
+
343
+ Use additional_fields for any field not exposed as a named parameter
344
+ (e.g., story points, remaining work, custom fields).
345
+ """
346
+ app_ctx: AppContext = ctx.request_context.lifespan_context
347
+ try:
348
+ organization = resolve_org(app_ctx, params.organization)
349
+ project = resolve_project(app_ctx, params.project)
350
+ url = build_url(organization, project, f"wit/workitems/{params.work_item_id}")
351
+
352
+ patch_ops: list[dict] = []
353
+ if params.title is not None:
354
+ patch_ops.append({"op": "add", "path": "/fields/System.Title", "value": params.title})
355
+ if params.description is not None:
356
+ patch_ops.append({"op": "add", "path": "/fields/System.Description", "value": params.description})
357
+ if params.assigned_to is not None:
358
+ patch_ops.append({"op": "add", "path": "/fields/System.AssignedTo", "value": params.assigned_to})
359
+ if params.state is not None:
360
+ patch_ops.append({"op": "add", "path": "/fields/System.State", "value": params.state})
361
+ if params.area_path is not None:
362
+ patch_ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": params.area_path})
363
+ if params.iteration_path is not None:
364
+ patch_ops.append({"op": "add", "path": "/fields/System.IterationPath", "value": params.iteration_path})
365
+ if params.priority is not None:
366
+ patch_ops.append({"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority", "value": params.priority})
367
+ if params.tags is not None:
368
+ patch_ops.append({"op": "add", "path": "/fields/System.Tags", "value": params.tags})
369
+ if params.comment is not None:
370
+ patch_ops.append({"op": "add", "path": "/fields/System.History", "value": params.comment})
371
+ if params.additional_fields:
372
+ for field_name, field_value in params.additional_fields.items():
373
+ patch_ops.append({"op": "add", "path": f"/fields/{field_name}", "value": field_value})
374
+
375
+ if not patch_ops:
376
+ return finalize_response({"error": True, "message": "No fields to update were provided."})
377
+
378
+ response = await request_with_retry(
379
+ app_ctx.http_client,
380
+ "PATCH",
381
+ url,
382
+ headers=await build_headers(
383
+ app_ctx,
384
+ extra={"Content-Type": "application/json-patch+json"},
385
+ ),
386
+ params={"api-version": _WIT_API_VERSION},
387
+ content=json.dumps(patch_ops).encode(),
388
+ )
389
+ response.raise_for_status()
390
+ return finalize_response(response.json())
391
+
392
+ except ValueError as e:
393
+ return finalize_response({"error": True, "message": str(e)})
394
+ except httpx.HTTPStatusError as e:
395
+ msg = extract_error_message(e.response)
396
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
397
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
398
+ except Exception as e:
399
+ logger.exception("Unexpected error in devops_update_work_item")
400
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
401
+
402
+
403
+ @write_tool(
404
+ name="devops_add_work_item_comment",
405
+ annotations={
406
+ "title": "Add Work Item Comment",
407
+ "readOnlyHint": False,
408
+ "destructiveHint": False,
409
+ "idempotentHint": False,
410
+ "openWorldHint": True,
411
+ },
412
+ )
413
+ async def devops_add_work_item_comment(params: AddWorkItemCommentInput, ctx: Context) -> str:
414
+ """Add a comment to an Azure DevOps work item.
415
+
416
+ Posts a new comment to the specified work item's discussion thread.
417
+ The comment text supports markdown formatting. Returns the created comment
418
+ object including its commentId, version, createdBy, and createdDate.
419
+ """
420
+ app_ctx: AppContext = ctx.request_context.lifespan_context
421
+ try:
422
+ organization = resolve_org(app_ctx, params.organization)
423
+ project = resolve_project(app_ctx, params.project)
424
+ url = build_url(organization, project, f"wit/workItems/{params.work_item_id}/comments")
425
+
426
+ response = await request_with_retry(
427
+ app_ctx.http_client,
428
+ "POST",
429
+ url,
430
+ headers=await build_headers(app_ctx, include_content_type=True),
431
+ params={"api-version": _WIT_COMMENTS_API_VERSION},
432
+ json={"text": params.text},
433
+ )
434
+ response.raise_for_status()
435
+ return finalize_response(response.json())
436
+
437
+ except ValueError as e:
438
+ return finalize_response({"error": True, "message": str(e)})
439
+ except httpx.HTTPStatusError as e:
440
+ msg = extract_error_message(e.response)
441
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
442
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
443
+ except Exception as e:
444
+ logger.exception("Unexpected error in devops_add_work_item_comment")
445
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
446
+
447
+
448
+ @write_tool(
449
+ name="devops_update_work_item_comment",
450
+ annotations={
451
+ "title": "Update Work Item Comment",
452
+ "readOnlyHint": False,
453
+ "destructiveHint": False,
454
+ "idempotentHint": True,
455
+ "openWorldHint": True,
456
+ },
457
+ )
458
+ async def devops_update_work_item_comment(params: UpdateWorkItemCommentInput, ctx: Context) -> str:
459
+ """Update an existing comment on an Azure DevOps work item.
460
+
461
+ Replaces the text of the specified comment. Use devops_add_work_item_comment
462
+ to get the commentId from the original add response, or retrieve existing
463
+ comment IDs via the Azure DevOps work item comments API. Returns the updated
464
+ comment object including the new version number.
465
+ """
466
+ app_ctx: AppContext = ctx.request_context.lifespan_context
467
+ try:
468
+ organization = resolve_org(app_ctx, params.organization)
469
+ project = resolve_project(app_ctx, params.project)
470
+ url = build_url(
471
+ organization,
472
+ project,
473
+ f"wit/workItems/{params.work_item_id}/comments/{params.comment_id}",
474
+ )
475
+
476
+ response = await request_with_retry(
477
+ app_ctx.http_client,
478
+ "PATCH",
479
+ url,
480
+ headers=await build_headers(app_ctx, include_content_type=True),
481
+ params={"api-version": _WIT_COMMENTS_API_VERSION},
482
+ json={"text": params.text},
483
+ )
484
+ response.raise_for_status()
485
+ return finalize_response(response.json())
486
+
487
+ except ValueError as e:
488
+ return finalize_response({"error": True, "message": str(e)})
489
+ except httpx.HTTPStatusError as e:
490
+ msg = extract_error_message(e.response)
491
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
492
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
493
+ except Exception as e:
494
+ logger.exception("Unexpected error in devops_update_work_item_comment")
495
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})