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.
devops_mcp/server.py ADDED
@@ -0,0 +1,30 @@
1
+ """FastMCP server for Azure DevOps MCP tools."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ # Configure logging to stderr (stdout reserved for stdio transport)
8
+ _log_level = os.environ.get("AZDO_LOG_LEVEL", "INFO").upper()
9
+ logging.basicConfig(
10
+ level=getattr(logging, _log_level, logging.DEBUG),
11
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
12
+ stream=sys.stderr,
13
+ )
14
+
15
+ from devops_mcp._app import mcp # noqa: E402
16
+
17
+ # Import tool modules to trigger @mcp.tool() registration
18
+ import devops_mcp.tools.pipelines # noqa: E402, F401
19
+ import devops_mcp.tools.pull_requests # noqa: E402, F401
20
+ import devops_mcp.tools.repositories # noqa: E402, F401
21
+ import devops_mcp.tools.work_items # noqa: E402, F401
22
+
23
+
24
+ def main():
25
+ """Entry point for the Azure DevOps MCP server."""
26
+ mcp.run()
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1 @@
1
+ """Azure DevOps MCP tools package."""
@@ -0,0 +1,412 @@
1
+ """Pipeline 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 (
22
+ GetBuildInput,
23
+ GetPipelineRunInput,
24
+ GetRunLogContentInput,
25
+ ListBuildArtifactsInput,
26
+ ListPipelineRunsInput,
27
+ ListPipelinesInput,
28
+ ListRunLogsInput,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @mcp.tool(
35
+ name="devops_list_pipelines",
36
+ annotations={
37
+ "title": "List Pipelines",
38
+ "readOnlyHint": True,
39
+ "destructiveHint": False,
40
+ "idempotentHint": True,
41
+ "openWorldHint": True,
42
+ },
43
+ )
44
+ async def devops_list_pipelines(params: ListPipelinesInput, ctx: Context) -> str:
45
+ """List pipelines defined in an Azure DevOps project.
46
+
47
+ Returns pipeline IDs, names, folder paths, and configuration types (yaml,
48
+ designerJson, etc.). Use the returned pipeline ID with
49
+ devops_list_pipeline_runs to see recent runs for a specific pipeline.
50
+ """
51
+ app_ctx: AppContext = ctx.request_context.lifespan_context
52
+ try:
53
+ organization = resolve_org(app_ctx, params.organization)
54
+ project = resolve_project(app_ctx, params.project)
55
+ url = build_url(organization, project, "pipelines")
56
+
57
+ effective_top = params.top if params.top is not None else 100
58
+ base_params = build_params(
59
+ **{
60
+ "$top": effective_top,
61
+ "orderBy": params.order_by,
62
+ }
63
+ )
64
+
65
+ headers = await build_headers(app_ctx)
66
+ pipelines, has_more = await paginate_results(
67
+ app_ctx.http_client,
68
+ url,
69
+ headers,
70
+ base_params,
71
+ record_key="value",
72
+ top=effective_top,
73
+ initial_continuation_token=params.continuation_token,
74
+ )
75
+
76
+ result: dict = {
77
+ "pipelines": pipelines,
78
+ "count": len(pipelines),
79
+ "has_more": has_more,
80
+ }
81
+ return finalize_response(result)
82
+
83
+ except ValueError as e:
84
+ return finalize_response({"error": True, "message": str(e)})
85
+ except httpx.HTTPStatusError as e:
86
+ msg = extract_error_message(e.response)
87
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
88
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
89
+ except Exception as e:
90
+ logger.exception("Unexpected error in devops_list_pipelines")
91
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
92
+
93
+
94
+ @mcp.tool(
95
+ name="devops_list_pipeline_runs",
96
+ annotations={
97
+ "title": "List Pipeline Runs",
98
+ "readOnlyHint": True,
99
+ "destructiveHint": False,
100
+ "idempotentHint": True,
101
+ "openWorldHint": True,
102
+ },
103
+ )
104
+ async def devops_list_pipeline_runs(params: ListPipelineRunsInput, ctx: Context) -> str:
105
+ """List runs for a specific Azure DevOps pipeline.
106
+
107
+ Returns run state (inProgress, completed), result (succeeded, failed, canceled),
108
+ timestamps, triggered branch/commit, and the run ID. The run ID is the same
109
+ as the build ID and can be used with devops_list_run_logs and
110
+ devops_list_build_artifacts.
111
+ """
112
+ app_ctx: AppContext = ctx.request_context.lifespan_context
113
+ try:
114
+ organization = resolve_org(app_ctx, params.organization)
115
+ project = resolve_project(app_ctx, params.project)
116
+ url = build_url(organization, project, f"pipelines/{params.pipeline_id}/runs")
117
+
118
+ response = await request_with_retry(
119
+ app_ctx.http_client,
120
+ "GET",
121
+ url,
122
+ headers=await build_headers(app_ctx),
123
+ params=build_params(),
124
+ )
125
+ response.raise_for_status()
126
+ data = response.json()
127
+
128
+ # The pipelines/{id}/runs endpoint (api-version 7.1) returns ALL runs in a single
129
+ # response — it supports neither a $top/top query parameter nor x-ms-continuationtoken
130
+ # server-side paging. Client-side slicing is therefore the only option, and applying
131
+ # the cap here is intentional (not a bug). has_more reflects whether the full set
132
+ # exceeded the requested cap.
133
+ runs = data.get("value", data) if isinstance(data, dict) else data
134
+ total_count = len(runs)
135
+ if params.top:
136
+ runs = runs[: params.top]
137
+ return finalize_response({
138
+ "runs": runs,
139
+ "count": len(runs),
140
+ "has_more": params.top is not None and total_count > params.top,
141
+ })
142
+
143
+ except ValueError as e:
144
+ return finalize_response({"error": True, "message": str(e)})
145
+ except httpx.HTTPStatusError as e:
146
+ msg = extract_error_message(e.response)
147
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
148
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
149
+ except Exception as e:
150
+ logger.exception("Unexpected error in devops_list_pipeline_runs")
151
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
152
+
153
+
154
+ @mcp.tool(
155
+ name="devops_get_pipeline_run",
156
+ annotations={
157
+ "title": "Get Pipeline Run",
158
+ "readOnlyHint": True,
159
+ "destructiveHint": False,
160
+ "idempotentHint": True,
161
+ "openWorldHint": True,
162
+ },
163
+ )
164
+ async def devops_get_pipeline_run(params: GetPipelineRunInput, ctx: Context) -> str:
165
+ """Get detailed information about a specific Azure DevOps pipeline run.
166
+
167
+ Returns run state, result, timestamps, triggered branch/commit, variables,
168
+ template parameters, and resource links. Use devops_list_run_logs to
169
+ retrieve log entries for this run.
170
+ """
171
+ app_ctx: AppContext = ctx.request_context.lifespan_context
172
+ try:
173
+ organization = resolve_org(app_ctx, params.organization)
174
+ project = resolve_project(app_ctx, params.project)
175
+ url = build_url(
176
+ organization, project,
177
+ f"pipelines/{params.pipeline_id}/runs/{params.run_id}",
178
+ )
179
+
180
+ response = await request_with_retry(
181
+ app_ctx.http_client,
182
+ "GET",
183
+ url,
184
+ headers=await build_headers(app_ctx),
185
+ params=build_params(),
186
+ )
187
+ response.raise_for_status()
188
+ return finalize_response(response.json())
189
+
190
+ except ValueError as e:
191
+ return finalize_response({"error": True, "message": str(e)})
192
+ except httpx.HTTPStatusError as e:
193
+ msg = extract_error_message(e.response)
194
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
195
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
196
+ except Exception as e:
197
+ logger.exception("Unexpected error in devops_get_pipeline_run")
198
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
199
+
200
+
201
+ @mcp.tool(
202
+ name="devops_list_run_logs",
203
+ annotations={
204
+ "title": "List Run Logs",
205
+ "readOnlyHint": True,
206
+ "destructiveHint": False,
207
+ "idempotentHint": True,
208
+ "openWorldHint": True,
209
+ },
210
+ )
211
+ async def devops_list_run_logs(params: ListRunLogsInput, ctx: Context) -> str:
212
+ """List log entries (metadata) for an Azure DevOps pipeline build/run.
213
+
214
+ Returns log IDs, line counts, and timestamps for each log in the build.
215
+ Accepts build_id directly — the 'buildId' value from a build URL
216
+ (e.g., dev.azure.com/org/project/_build/results?buildId=12345).
217
+ Use the returned log IDs with devops_get_run_log_content to fetch log text.
218
+ """
219
+ app_ctx: AppContext = ctx.request_context.lifespan_context
220
+ try:
221
+ organization = resolve_org(app_ctx, params.organization)
222
+ project = resolve_project(app_ctx, params.project)
223
+ url = build_url(
224
+ organization, project,
225
+ f"build/builds/{params.build_id}/logs",
226
+ )
227
+
228
+ response = await request_with_retry(
229
+ app_ctx.http_client,
230
+ "GET",
231
+ url,
232
+ headers=await build_headers(app_ctx),
233
+ params=build_params(),
234
+ )
235
+ response.raise_for_status()
236
+ data = response.json()
237
+ logs = data.get("value", []) if isinstance(data, dict) else data
238
+ return finalize_response({"logs": logs, "count": len(logs)})
239
+
240
+ except ValueError as e:
241
+ return finalize_response({"error": True, "message": str(e)})
242
+ except httpx.HTTPStatusError as e:
243
+ msg = extract_error_message(e.response)
244
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
245
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
246
+ except Exception as e:
247
+ logger.exception("Unexpected error in devops_list_run_logs")
248
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
249
+
250
+
251
+ @mcp.tool(
252
+ name="devops_get_build",
253
+ annotations={
254
+ "title": "Get Build",
255
+ "readOnlyHint": True,
256
+ "destructiveHint": False,
257
+ "idempotentHint": True,
258
+ "openWorldHint": True,
259
+ },
260
+ )
261
+ async def devops_get_build(params: GetBuildInput, ctx: Context) -> str:
262
+ """Get details of a specific Azure DevOps build by build ID.
263
+
264
+ Accepts the build_id directly from a build URL
265
+ (e.g., dev.azure.com/org/project/_build/results?buildId=12345).
266
+ Returns build status, result, branch, commit, triggered-by info, and
267
+ the pipeline definition ID and name — useful for resolving a build URL
268
+ to the pipeline_id needed by other tools.
269
+ """
270
+ app_ctx: AppContext = ctx.request_context.lifespan_context
271
+ try:
272
+ organization = resolve_org(app_ctx, params.organization)
273
+ project = resolve_project(app_ctx, params.project)
274
+ url = build_url(organization, project, f"build/builds/{params.build_id}")
275
+
276
+ response = await request_with_retry(
277
+ app_ctx.http_client,
278
+ "GET",
279
+ url,
280
+ headers=await build_headers(app_ctx),
281
+ params=build_params(),
282
+ )
283
+ response.raise_for_status()
284
+ return finalize_response(response.json())
285
+
286
+ except ValueError as e:
287
+ return finalize_response({"error": True, "message": str(e)})
288
+ except httpx.HTTPStatusError as e:
289
+ msg = extract_error_message(e.response)
290
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
291
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
292
+ except Exception as e:
293
+ logger.exception("Unexpected error in devops_get_build")
294
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
295
+
296
+
297
+ @mcp.tool(
298
+ name="devops_get_run_log_content",
299
+ annotations={
300
+ "title": "Get Run Log Content",
301
+ "readOnlyHint": True,
302
+ "destructiveHint": False,
303
+ "idempotentHint": True,
304
+ "openWorldHint": True,
305
+ },
306
+ )
307
+ async def devops_get_run_log_content(params: GetRunLogContentInput, ctx: Context) -> str:
308
+ """Get the plain-text content of a specific log from an Azure DevOps pipeline run.
309
+
310
+ Uses the Build API to retrieve actual log text. The build_id is the same as
311
+ the run_id for a given run. Use devops_list_run_logs first to discover
312
+ available log IDs and their line counts.
313
+
314
+ Use start_line and end_line to fetch a specific portion of large logs.
315
+ """
316
+ app_ctx: AppContext = ctx.request_context.lifespan_context
317
+ try:
318
+ organization = resolve_org(app_ctx, params.organization)
319
+ project = resolve_project(app_ctx, params.project)
320
+ url = build_url(
321
+ organization, project,
322
+ f"build/builds/{params.build_id}/logs/{params.log_id}",
323
+ )
324
+ query_params = build_params(
325
+ startLine=params.start_line,
326
+ endLine=params.end_line,
327
+ )
328
+
329
+ headers = await build_headers(app_ctx)
330
+ headers["Accept"] = "text/plain"
331
+ response = await request_with_retry(
332
+ app_ctx.http_client,
333
+ "GET",
334
+ url,
335
+ headers=headers,
336
+ params=query_params,
337
+ )
338
+ response.raise_for_status()
339
+ return finalize_response({
340
+ "build_id": params.build_id,
341
+ "log_id": params.log_id,
342
+ "content": response.text,
343
+ })
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_get_run_log_content")
353
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
354
+
355
+
356
+ @mcp.tool(
357
+ name="devops_list_build_artifacts",
358
+ annotations={
359
+ "title": "List Build Artifacts",
360
+ "readOnlyHint": True,
361
+ "destructiveHint": False,
362
+ "idempotentHint": True,
363
+ "openWorldHint": True,
364
+ },
365
+ )
366
+ async def devops_list_build_artifacts(params: ListBuildArtifactsInput, ctx: Context) -> str:
367
+ """List artifacts produced by an Azure DevOps pipeline build.
368
+
369
+ Returns artifact names, types, and download URLs for each artifact
370
+ associated with the build. The build_id is the same as the run_id.
371
+ Optionally filter to a specific artifact by name.
372
+ """
373
+ app_ctx: AppContext = ctx.request_context.lifespan_context
374
+ try:
375
+ organization = resolve_org(app_ctx, params.organization)
376
+ project = resolve_project(app_ctx, params.project)
377
+ url = build_url(
378
+ organization, project,
379
+ f"build/builds/{params.build_id}/artifacts",
380
+ )
381
+ query_params = build_params(artifactName=params.artifact_name)
382
+
383
+ response = await request_with_retry(
384
+ app_ctx.http_client,
385
+ "GET",
386
+ url,
387
+ headers=await build_headers(app_ctx),
388
+ params=query_params,
389
+ )
390
+ response.raise_for_status()
391
+ data = response.json()
392
+
393
+ if isinstance(data, list):
394
+ artifacts = data
395
+ elif isinstance(data, dict) and "value" in data:
396
+ artifacts = data["value"]
397
+ elif isinstance(data, dict):
398
+ artifacts = [data]
399
+ else:
400
+ artifacts = []
401
+
402
+ return finalize_response({"artifacts": artifacts, "count": len(artifacts)})
403
+
404
+ except ValueError as e:
405
+ return finalize_response({"error": True, "message": str(e)})
406
+ except httpx.HTTPStatusError as e:
407
+ msg = extract_error_message(e.response)
408
+ logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
409
+ return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
410
+ except Exception as e:
411
+ logger.exception("Unexpected error in devops_list_build_artifacts")
412
+ return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})