az-devops-cli-mcp 1.0.2__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,54 @@
1
+ """
2
+ az-devops-cli-mcp
3
+ ===============
4
+ Azure DevOps CLI MCP server — boards, work items, repos, PRs.
5
+
6
+ Usage in your agent:
7
+ from az_devops_cli_mcp import ALL_TOOLS, WORK_ITEM_TOOLS, REPO_TOOLS
8
+ """
9
+
10
+ from az_devops_cli_mcp.tools.work_items import (
11
+ create_work_item,
12
+ get_work_item,
13
+ update_work_item,
14
+ set_iteration,
15
+ query_work_items,
16
+ link_work_items,
17
+ get_work_item_relations,
18
+ add_comment,
19
+ )
20
+
21
+ from az_devops_cli_mcp.tools.repos import (
22
+ list_prs,
23
+ get_pr,
24
+ create_pr,
25
+ link_pr_to_work_item,
26
+ list_branches,
27
+ )
28
+
29
+ WORK_ITEM_TOOLS = [
30
+ create_work_item,
31
+ get_work_item,
32
+ update_work_item,
33
+ set_iteration,
34
+ query_work_items,
35
+ link_work_items,
36
+ get_work_item_relations,
37
+ add_comment,
38
+ ]
39
+
40
+ REPO_TOOLS = [
41
+ list_prs,
42
+ get_pr,
43
+ create_pr,
44
+ link_pr_to_work_item,
45
+ list_branches,
46
+ ]
47
+
48
+ ALL_TOOLS = WORK_ITEM_TOOLS + REPO_TOOLS
49
+
50
+ __all__ = [
51
+ "ALL_TOOLS",
52
+ "WORK_ITEM_TOOLS",
53
+ "REPO_TOOLS",
54
+ ]
@@ -0,0 +1,6 @@
1
+ """
2
+ Allows: python -m az_devops_cli_mcp.server
3
+ and: python -m az_devops_cli_mcp (same thing)
4
+ """
5
+ from az_devops_cli_mcp.server import run
6
+ run()
@@ -0,0 +1,50 @@
1
+ """
2
+ az_devops_cli_mcp.config
3
+ ======================
4
+ Resolves the Azure DevOps org URL in this priority order:
5
+
6
+ 1. AZURE_DEVOPS_ORG environment variable (or .env file)
7
+ 2. az devops configure --list (whatever the user already set)
8
+
9
+ This means each team member just runs:
10
+ az login
11
+ az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
12
+
13
+ ...and the tools pick it up automatically.
14
+ No hardcoded values, no shared secrets.
15
+ """
16
+
17
+ import subprocess
18
+ import os
19
+ from dotenv import load_dotenv
20
+
21
+ load_dotenv() # load .env if present (optional, never required)
22
+
23
+
24
+ def get_org() -> str:
25
+ """
26
+ Resolve the Azure DevOps org URL.
27
+ Raises RuntimeError if neither env var nor az defaults are set.
28
+ """
29
+ # 1. Env override
30
+ org = os.getenv("AZURE_DEVOPS_ORG", "").strip()
31
+ if org:
32
+ return org
33
+
34
+ # 2. Read from az devops configure --list
35
+ result = subprocess.run(
36
+ "az devops configure --list",
37
+ shell=True, capture_output=True, text=True
38
+ )
39
+ for line in result.stdout.splitlines():
40
+ if line.strip().startswith("organization"):
41
+ _, _, value = line.partition("=")
42
+ org = value.strip()
43
+ if org:
44
+ return org
45
+
46
+ raise RuntimeError(
47
+ "No Azure DevOps org configured.\n"
48
+ "Run: az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG\n"
49
+ "Or set AZURE_DEVOPS_ORG in your environment."
50
+ )
@@ -0,0 +1,76 @@
1
+ """
2
+ az_devops_cli_mcp.core
3
+ ====================
4
+ Shared helpers used by all tool modules.
5
+ """
6
+
7
+ import json
8
+ import subprocess
9
+ from az_devops_cli_mcp.config import get_org
10
+
11
+ BLOCKED_COMMANDS = ["delete", "remove", "destroy"]
12
+
13
+
14
+ def az(args: str) -> dict | list:
15
+ """
16
+ Execute an az CLI command against the configured org.
17
+ Returns parsed JSON on success, {"error": "..."} on failure.
18
+ Blocks destructive commands until the user confirms.
19
+ """
20
+ if any(cmd in args.lower() for cmd in BLOCKED_COMMANDS):
21
+ return {
22
+ "error": (
23
+ f"Blocked: destructive command detected in '{args}'. "
24
+ "Confirm with user before proceeding."
25
+ )
26
+ }
27
+
28
+ try:
29
+ org = get_org()
30
+ except RuntimeError as e:
31
+ return {"error": str(e)}
32
+
33
+ result = subprocess.run(
34
+ f"az {args} --output json --org {org}",
35
+ shell=True,
36
+ capture_output=True,
37
+ text=True
38
+ )
39
+
40
+ if result.returncode != 0:
41
+ return {"error": result.stderr.strip()}
42
+
43
+ try:
44
+ return json.loads(result.stdout)
45
+ except json.JSONDecodeError:
46
+ return {"error": f"Failed to parse response: {result.stdout.strip()}"}
47
+
48
+
49
+ def toon(data: dict | list, summary: str) -> str:
50
+ """
51
+ Token-Oriented Object Notation:
52
+ Flat dict/list → key=value pipe-separated (token-efficient)
53
+ Nested objects → full JSON fallback
54
+ Error dict → [ERROR] prefix
55
+ Always prepends a summary line.
56
+ """
57
+ if isinstance(data, dict) and "error" in data:
58
+ return f"[ERROR] {data['error']}"
59
+
60
+ def is_flat(obj: dict) -> bool:
61
+ return isinstance(obj, dict) and all(
62
+ not isinstance(v, (dict, list)) for v in obj.values()
63
+ )
64
+
65
+ if isinstance(data, dict) and is_flat(data):
66
+ body = " | ".join(f"{k}={v}" for k, v in data.items())
67
+
68
+ elif isinstance(data, list) and all(is_flat(i) for i in data):
69
+ body = "(none)" if not data else "\n".join(
70
+ " | ".join(f"{k}={v}" for k, v in item.items())
71
+ for item in data
72
+ )
73
+ else:
74
+ body = json.dumps(data, indent=2)
75
+
76
+ return f"[{summary}]\n{body}"
@@ -0,0 +1,351 @@
1
+ """
2
+ az_devops_cli_mcp.server
3
+ ======================
4
+ stdio MCP server — spawned on demand by Claude Code / Codex CLI.
5
+ No ports, no background processes. The client owns the lifecycle.
6
+
7
+ Start manually (for testing):
8
+ python -m az_devops_cli_mcp.server
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ from mcp.server import Server
14
+ from mcp.server.stdio import stdio_server
15
+ from mcp.types import Tool, TextContent
16
+
17
+ from az_devops_cli_mcp.core import az, toon
18
+ from az_devops_cli_mcp.config import get_org
19
+
20
+ # ── Tool schemas ──────────────────────────────────────────────────────────────
21
+
22
+ TOOLS: list[Tool] = [
23
+
24
+ # ── Work Items ──────────────────────────────────────────────────────────
25
+
26
+ Tool(
27
+ name="create_work_item",
28
+ description="Create a work item on the board. type options: Task, Bug, User Story, Epic, Feature.",
29
+ inputSchema={
30
+ "type": "object",
31
+ "properties": {
32
+ "project": {"type": "string", "description": "Azure DevOps project name"},
33
+ "title": {"type": "string", "description": "Work item title"},
34
+ "type": {"type": "string", "description": "Work item type", "default": "Task"},
35
+ "assigned_to": {"type": "string", "description": "Assignee display name (optional)"},
36
+ },
37
+ "required": ["project", "title"],
38
+ },
39
+ ),
40
+
41
+ Tool(
42
+ name="get_work_item",
43
+ description="Get a work item by ID.",
44
+ inputSchema={
45
+ "type": "object",
46
+ "properties": {
47
+ "id": {"type": "integer", "description": "Work item ID"},
48
+ },
49
+ "required": ["id"],
50
+ },
51
+ ),
52
+
53
+ Tool(
54
+ name="update_work_item",
55
+ description="Update state, assignment, or title of a work item.",
56
+ inputSchema={
57
+ "type": "object",
58
+ "properties": {
59
+ "id": {"type": "integer", "description": "Work item ID"},
60
+ "state": {"type": "string", "description": "New state (e.g. Active, Resolved, Closed)"},
61
+ "assigned_to": {"type": "string", "description": "New assignee display name"},
62
+ "title": {"type": "string", "description": "New title"},
63
+ },
64
+ "required": ["id"],
65
+ },
66
+ ),
67
+
68
+ Tool(
69
+ name="set_iteration",
70
+ description=(
71
+ "Assign a work item to a sprint iteration. "
72
+ "Required for items to appear on the Sprint board. "
73
+ "Format: 'ProjectName\\\\Sprint N'"
74
+ ),
75
+ inputSchema={
76
+ "type": "object",
77
+ "properties": {
78
+ "id": {"type": "integer", "description": "Work item ID"},
79
+ "iteration_path": {"type": "string", "description": "e.g. LytStore\\\\Sprint 4"},
80
+ },
81
+ "required": ["id", "iteration_path"],
82
+ },
83
+ ),
84
+
85
+ Tool(
86
+ name="query_work_items",
87
+ description="Run a WIQL query to find work items. Use for any bulk lookups.",
88
+ inputSchema={
89
+ "type": "object",
90
+ "properties": {
91
+ "project": {"type": "string", "description": "Azure DevOps project name"},
92
+ "wiql": {"type": "string", "description": "WIQL query string"},
93
+ },
94
+ "required": ["project", "wiql"],
95
+ },
96
+ ),
97
+
98
+ Tool(
99
+ name="link_work_items",
100
+ description="Link two work items. link_type options: Related, Parent, Child, Duplicate, Successor, Predecessor.",
101
+ inputSchema={
102
+ "type": "object",
103
+ "properties": {
104
+ "source_id": {"type": "integer", "description": "Source work item ID"},
105
+ "target_id": {"type": "integer", "description": "Target work item ID"},
106
+ "link_type": {"type": "string", "description": "Relationship type", "default": "Related"},
107
+ },
108
+ "required": ["source_id", "target_id"],
109
+ },
110
+ ),
111
+
112
+ Tool(
113
+ name="get_work_item_relations",
114
+ description="Get all linked work items. Use after bulk linking to verify hierarchy.",
115
+ inputSchema={
116
+ "type": "object",
117
+ "properties": {
118
+ "id": {"type": "integer", "description": "Work item ID"},
119
+ },
120
+ "required": ["id"],
121
+ },
122
+ ),
123
+
124
+ Tool(
125
+ name="add_comment",
126
+ description="Add a discussion comment to a work item. Use to log agent reasoning or decisions.",
127
+ inputSchema={
128
+ "type": "object",
129
+ "properties": {
130
+ "id": {"type": "integer", "description": "Work item ID"},
131
+ "comment": {"type": "string", "description": "Comment text"},
132
+ },
133
+ "required": ["id", "comment"],
134
+ },
135
+ ),
136
+
137
+ # ── Repos / PRs ─────────────────────────────────────────────────────────
138
+
139
+ Tool(
140
+ name="list_prs",
141
+ description="List pull requests in a project. status options: active, completed, abandoned, all.",
142
+ inputSchema={
143
+ "type": "object",
144
+ "properties": {
145
+ "project": {"type": "string", "description": "Azure DevOps project name"},
146
+ "status": {"type": "string", "description": "PR status filter", "default": "active"},
147
+ },
148
+ "required": ["project"],
149
+ },
150
+ ),
151
+
152
+ Tool(
153
+ name="get_pr",
154
+ description="Get full details of a specific PR.",
155
+ inputSchema={
156
+ "type": "object",
157
+ "properties": {
158
+ "pr_id": {"type": "integer", "description": "Pull request ID"},
159
+ },
160
+ "required": ["pr_id"],
161
+ },
162
+ ),
163
+
164
+ Tool(
165
+ name="create_pr",
166
+ description="Create a pull request.",
167
+ inputSchema={
168
+ "type": "object",
169
+ "properties": {
170
+ "project": {"type": "string", "description": "Azure DevOps project name"},
171
+ "repo": {"type": "string", "description": "Repository name"},
172
+ "title": {"type": "string", "description": "PR title"},
173
+ "source": {"type": "string", "description": "Source branch"},
174
+ "target": {"type": "string", "description": "Target branch", "default": "main"},
175
+ "description": {"type": "string", "description": "PR description (optional)"},
176
+ },
177
+ "required": ["project", "repo", "title", "source"],
178
+ },
179
+ ),
180
+
181
+ Tool(
182
+ name="link_pr_to_work_item",
183
+ description="Link a PR to a work item for full board-to-code traceability. Call every time a PR is raised.",
184
+ inputSchema={
185
+ "type": "object",
186
+ "properties": {
187
+ "pr_id": {"type": "integer", "description": "Pull request ID"},
188
+ "work_item_id": {"type": "integer", "description": "Work item ID"},
189
+ },
190
+ "required": ["pr_id", "work_item_id"],
191
+ },
192
+ ),
193
+
194
+ Tool(
195
+ name="list_branches",
196
+ description="List all branches in a repository.",
197
+ inputSchema={
198
+ "type": "object",
199
+ "properties": {
200
+ "project": {"type": "string", "description": "Azure DevOps project name"},
201
+ "repo": {"type": "string", "description": "Repository name"},
202
+ },
203
+ "required": ["project", "repo"],
204
+ },
205
+ ),
206
+ ]
207
+
208
+ # ── Tool dispatch ─────────────────────────────────────────────────────────────
209
+
210
+ def dispatch(name: str, args: dict) -> str:
211
+ """Route tool calls to az CLI and return toon-formatted results."""
212
+
213
+ # Work Items
214
+ if name == "create_work_item":
215
+ cmd = f'boards work-item create --project {args["project"]} --title "{args["title"]}" --type "{args.get("type", "Task")}"'
216
+ if args.get("assigned_to"):
217
+ cmd += f' --assigned-to "{args["assigned_to"]}"'
218
+ data = az(cmd)
219
+ if "error" in data:
220
+ return toon(data, "create_work_item failed")
221
+ f = data["fields"]
222
+ return toon(
223
+ {"id": data["id"], "title": f["System.Title"], "state": f["System.State"], "type": args.get("type", "Task")},
224
+ f"Created {args.get('type', 'Task')} #{data['id']} in {args['project']}"
225
+ )
226
+
227
+ if name == "get_work_item":
228
+ data = az(f"boards work-item show --id {args['id']}")
229
+ if "error" in data:
230
+ return toon(data, "get_work_item failed")
231
+ f = data["fields"]
232
+ assigned = f.get("System.AssignedTo", {})
233
+ return toon(
234
+ {
235
+ "id": data["id"],
236
+ "title": f["System.Title"],
237
+ "state": f["System.State"],
238
+ "type": f["System.WorkItemType"],
239
+ "iteration": f.get("System.IterationPath", "unset"),
240
+ "assigned_to": assigned.get("displayName", "unassigned") if isinstance(assigned, dict) else assigned,
241
+ },
242
+ f"Work item #{args['id']}"
243
+ )
244
+
245
+ if name == "update_work_item":
246
+ cmd = f"boards work-item update --id {args['id']}"
247
+ if args.get("state"): cmd += f' --state "{args["state"]}"'
248
+ if args.get("assigned_to"): cmd += f' --assigned-to "{args["assigned_to"]}"'
249
+ if args.get("title"): cmd += f' --title "{args["title"]}"'
250
+ data = az(cmd)
251
+ if "error" in data:
252
+ return toon(data, "update_work_item failed")
253
+ f = data["fields"]
254
+ return toon({"id": args["id"], "state": f["System.State"], "title": f["System.Title"]}, f"Updated #{args['id']}")
255
+
256
+ if name == "set_iteration":
257
+ data = az(f'boards work-item update --id {args["id"]} --iteration "{args["iteration_path"]}"')
258
+ if "error" in data:
259
+ return toon(data, "set_iteration failed")
260
+ return toon({"id": args["id"], "iteration": data["fields"]["System.IterationPath"]}, f"Assigned #{args['id']} to {args['iteration_path']}")
261
+
262
+ if name == "query_work_items":
263
+ data = az(f'boards query --project {args["project"]} --wiql "{args["wiql"]}"')
264
+ if isinstance(data, dict) and "error" in data:
265
+ return toon(data, "query_work_items failed")
266
+ items = [{"id": i["id"], "title": i["fields"]["System.Title"], "state": i["fields"]["System.State"], "iteration": i["fields"].get("System.IterationPath", "unset")} for i in data]
267
+ return toon(items, f"{len(items)} work items in {args['project']}")
268
+
269
+ if name == "link_work_items":
270
+ data = az(f"boards work-item relation add --id {args['source_id']} --target-id {args['target_id']} --relation-type {args.get('link_type', 'Related')}")
271
+ if isinstance(data, dict) and "error" in data:
272
+ return toon(data, "link_work_items failed")
273
+ return toon({"source": args["source_id"], "target": args["target_id"], "link": args.get("link_type", "Related")}, f"Linked #{args['source_id']} → #{args['target_id']}")
274
+
275
+ if name == "get_work_item_relations":
276
+ data = az(f"boards work-item show --id {args['id']} --expand relations")
277
+ if "error" in data:
278
+ return toon(data, "get_work_item_relations failed")
279
+ relations = [{"related_id": r["url"].split("/")[-1], "type": r.get("rel", "unknown"), "name": r.get("attributes", {}).get("name", "")} for r in data.get("relations", [])]
280
+ return toon(relations, f"{len(relations)} relations for #{args['id']}")
281
+
282
+ if name == "add_comment":
283
+ data = az(f'boards work-item update --id {args["id"]} --discussion "{args["comment"]}"')
284
+ if "error" in data:
285
+ return toon(data, "add_comment failed")
286
+ return toon({"id": args["id"]}, f"Comment added to #{args['id']}")
287
+
288
+ # Repos / PRs
289
+ if name == "list_prs":
290
+ data = az(f"repos pr list --project {args['project']} --status {args.get('status', 'active')}")
291
+ if isinstance(data, dict) and "error" in data:
292
+ return toon(data, "list_prs failed")
293
+ items = [{"id": p["pullRequestId"], "title": p["title"], "source": p["sourceRefName"].replace("refs/heads/", ""), "target": p["targetRefName"].replace("refs/heads/", ""), "author": p["createdBy"]["displayName"], "status": p["status"]} for p in data]
294
+ return toon(items, f"{len(items)} {args.get('status', 'active')} PRs in {args['project']}")
295
+
296
+ if name == "get_pr":
297
+ data = az(f"repos pr show --id {args['pr_id']}")
298
+ if "error" in data:
299
+ return toon(data, "get_pr failed")
300
+ return toon({"id": args["pr_id"], "title": data["title"], "status": data["status"], "source": data["sourceRefName"].replace("refs/heads/", ""), "target": data["targetRefName"].replace("refs/heads/", ""), "author": data["createdBy"]["displayName"], "merge_status": data["mergeStatus"]}, f"PR #{args['pr_id']}")
301
+
302
+ if name == "create_pr":
303
+ cmd = f'repos pr create --project {args["project"]} --repository {args["repo"]} --title "{args["title"]}" --source-branch {args["source"]} --target-branch {args.get("target", "main")}'
304
+ if args.get("description"):
305
+ cmd += f' --description "{args["description"]}"'
306
+ data = az(cmd)
307
+ if "error" in data:
308
+ return toon(data, "create_pr failed")
309
+ return toon({"id": data["pullRequestId"], "title": args["title"], "source": args["source"], "target": args.get("target", "main"), "status": data["status"]}, f"Created PR #{data['pullRequestId']}")
310
+
311
+ if name == "link_pr_to_work_item":
312
+ data = az(f"repos pr work-item add --id {args['pr_id']} --work-items {args['work_item_id']}")
313
+ if isinstance(data, dict) and "error" in data:
314
+ return toon(data, "link_pr_to_work_item failed")
315
+ return toon({"pr": args["pr_id"], "work_item": args["work_item_id"]}, f"Linked PR #{args['pr_id']} ↔ work item #{args['work_item_id']}")
316
+
317
+ if name == "list_branches":
318
+ data = az(f"repos ref list --project {args['project']} --repository {args['repo']} --filter heads/")
319
+ if isinstance(data, dict) and "error" in data:
320
+ return toon(data, "list_branches failed")
321
+ items = [{"name": r["name"].replace("refs/heads/", ""), "commit": r["objectId"][:7], "creator": r.get("creator", {}).get("displayName", "unknown")} for r in data]
322
+ return toon(items, f"{len(items)} branches in {args['project']}/{args['repo']}")
323
+
324
+ return f"[ERROR] Unknown tool: {name}"
325
+
326
+
327
+ # ── Server entrypoint ─────────────────────────────────────────────────────────
328
+
329
+ async def main():
330
+ server = Server("az-devops-cli-mcp")
331
+
332
+ @server.list_tools()
333
+ async def list_tools() -> list[Tool]:
334
+ return TOOLS
335
+
336
+ @server.call_tool()
337
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
338
+ result = dispatch(name, arguments)
339
+ return [TextContent(type="text", text=result)]
340
+
341
+ async with stdio_server() as (read_stream, write_stream):
342
+ await server.run(read_stream, write_stream, server.create_initialization_options())
343
+
344
+
345
+ if __name__ == "__main__":
346
+ asyncio.run(main())
347
+
348
+
349
+ def run():
350
+ """Console script entrypoint — called by `az-devops-cli-mcp` command."""
351
+ asyncio.run(main())
File without changes
@@ -0,0 +1,124 @@
1
+ """
2
+ az_devops_cli_mcp.tools.repos
3
+ ============================
4
+ AG2 tools for Azure DevOps Repos — PRs, branches, PR-to-work-item traceability.
5
+ """
6
+
7
+ try:
8
+ from autogen import tool
9
+ except ImportError:
10
+ def tool(fn):
11
+ return fn
12
+ from az_devops_cli_mcp.core import az, toon
13
+
14
+
15
+ @tool
16
+ def list_prs(project: str, status: str = "active") -> str:
17
+ """
18
+ List pull requests in a project.
19
+ status options: active, completed, abandoned, all
20
+ """
21
+ data = az(f"repos pr list --project {project} --status {status}")
22
+ if isinstance(data, dict) and "error" in data:
23
+ return toon(data, "list_prs failed")
24
+
25
+ items = [
26
+ {
27
+ "id": p["pullRequestId"],
28
+ "title": p["title"],
29
+ "source": p["sourceRefName"].replace("refs/heads/", ""),
30
+ "target": p["targetRefName"].replace("refs/heads/", ""),
31
+ "author": p["createdBy"]["displayName"],
32
+ "status": p["status"],
33
+ }
34
+ for p in data
35
+ ]
36
+ return toon(items, f"{len(items)} {status} PRs in {project}")
37
+
38
+
39
+ @tool
40
+ def get_pr(pr_id: int) -> str:
41
+ """Get full details of a specific PR."""
42
+ data = az(f"repos pr show --id {pr_id}")
43
+ if "error" in data:
44
+ return toon(data, "get_pr failed")
45
+
46
+ return toon(
47
+ {
48
+ "id": pr_id,
49
+ "title": data["title"],
50
+ "status": data["status"],
51
+ "source": data["sourceRefName"].replace("refs/heads/", ""),
52
+ "target": data["targetRefName"].replace("refs/heads/", ""),
53
+ "author": data["createdBy"]["displayName"],
54
+ "merge_status": data["mergeStatus"],
55
+ },
56
+ f"PR #{pr_id}: {data['title']}"
57
+ )
58
+
59
+
60
+ @tool
61
+ def create_pr(
62
+ project: str,
63
+ repo: str,
64
+ title: str,
65
+ source: str,
66
+ target: str = "main",
67
+ description: str = "",
68
+ ) -> str:
69
+ """Create a pull request."""
70
+ args = (
71
+ f'repos pr create --project {project} --repository {repo} '
72
+ f'--title "{title}" --source-branch {source} --target-branch {target}'
73
+ )
74
+ if description:
75
+ args += f' --description "{description}"'
76
+
77
+ data = az(args)
78
+ if "error" in data:
79
+ return toon(data, "create_pr failed")
80
+
81
+ return toon(
82
+ {
83
+ "id": data["pullRequestId"],
84
+ "title": title,
85
+ "source": source,
86
+ "target": target,
87
+ "status": data["status"],
88
+ },
89
+ f"Created PR #{data['pullRequestId']} in {project}/{repo}"
90
+ )
91
+
92
+
93
+ @tool
94
+ def link_pr_to_work_item(pr_id: int, work_item_id: int) -> str:
95
+ """
96
+ Link a PR to a work item for full board-to-code traceability.
97
+ Call every time a PR is raised against an active task.
98
+ """
99
+ data = az(f"repos pr work-item add --id {pr_id} --work-items {work_item_id}")
100
+ if isinstance(data, dict) and "error" in data:
101
+ return toon(data, "link_pr_to_work_item failed")
102
+
103
+ return toon(
104
+ {"pr": pr_id, "work_item": work_item_id},
105
+ f"Linked PR #{pr_id} ↔ work item #{work_item_id}"
106
+ )
107
+
108
+
109
+ @tool
110
+ def list_branches(project: str, repo: str) -> str:
111
+ """List all branches in a repo."""
112
+ data = az(f"repos ref list --project {project} --repository {repo} --filter heads/")
113
+ if isinstance(data, dict) and "error" in data:
114
+ return toon(data, "list_branches failed")
115
+
116
+ items = [
117
+ {
118
+ "name": r["name"].replace("refs/heads/", ""),
119
+ "commit": r["objectId"][:7],
120
+ "creator": r.get("creator", {}).get("displayName", "unknown"),
121
+ }
122
+ for r in data
123
+ ]
124
+ return toon(items, f"{len(items)} branches in {project}/{repo}")
@@ -0,0 +1,167 @@
1
+ """
2
+ az_devops_cli_mcp.tools.work_items
3
+ =================================
4
+ AG2 tools for Azure DevOps Boards — work items, queries, linking, iteration.
5
+ """
6
+
7
+ try:
8
+ from autogen import tool
9
+ except ImportError:
10
+ def tool(fn):
11
+ return fn
12
+ from az_devops_cli_mcp.core import az, toon
13
+
14
+
15
+ @tool
16
+ def create_work_item(project: str, title: str, type: str = "Task", assigned_to: str = "") -> str:
17
+ """
18
+ Create a work item on the board.
19
+ type options: Task, Bug, User Story, Epic, Feature
20
+ """
21
+ args = f'boards work-item create --project {project} --title "{title}" --type "{type}"'
22
+ if assigned_to:
23
+ args += f' --assigned-to "{assigned_to}"'
24
+
25
+ data = az(args)
26
+ if "error" in data:
27
+ return toon(data, "create_work_item failed")
28
+
29
+ f = data["fields"]
30
+ return toon(
31
+ {"id": data["id"], "title": f["System.Title"], "state": f["System.State"], "type": type},
32
+ f"Created {type} #{data['id']} in {project}"
33
+ )
34
+
35
+
36
+ @tool
37
+ def get_work_item(id: int) -> str:
38
+ """Get a work item by ID."""
39
+ data = az(f"boards work-item show --id {id}")
40
+ if "error" in data:
41
+ return toon(data, "get_work_item failed")
42
+
43
+ f = data["fields"]
44
+ assigned = f.get("System.AssignedTo", {})
45
+ return toon(
46
+ {
47
+ "id": data["id"],
48
+ "title": f["System.Title"],
49
+ "state": f["System.State"],
50
+ "type": f["System.WorkItemType"],
51
+ "iteration": f.get("System.IterationPath", "unset"),
52
+ "assigned_to": assigned.get("displayName", "unassigned") if isinstance(assigned, dict) else assigned,
53
+ },
54
+ f"Work item #{id}"
55
+ )
56
+
57
+
58
+ @tool
59
+ def update_work_item(id: int, state: str = "", assigned_to: str = "", title: str = "") -> str:
60
+ """Update state, assignment, or title of a work item."""
61
+ args = f"boards work-item update --id {id}"
62
+ if state: args += f' --state "{state}"'
63
+ if assigned_to: args += f' --assigned-to "{assigned_to}"'
64
+ if title: args += f' --title "{title}"'
65
+
66
+ data = az(args)
67
+ if "error" in data:
68
+ return toon(data, "update_work_item failed")
69
+
70
+ f = data["fields"]
71
+ return toon(
72
+ {"id": id, "state": f["System.State"], "title": f["System.Title"]},
73
+ f"Updated work item #{id}"
74
+ )
75
+
76
+
77
+ @tool
78
+ def set_iteration(id: int, iteration_path: str) -> str:
79
+ """
80
+ Assign a work item to a sprint iteration.
81
+ iteration_path format: 'LytStore\\\\Sprint 4'
82
+ Must be called for items to appear on the Sprint board view.
83
+ """
84
+ data = az(f'boards work-item update --id {id} --iteration "{iteration_path}"')
85
+ if "error" in data:
86
+ return toon(data, "set_iteration failed")
87
+
88
+ f = data["fields"]
89
+ return toon(
90
+ {"id": id, "iteration": f["System.IterationPath"]},
91
+ f"Assigned #{id} to {iteration_path}"
92
+ )
93
+
94
+
95
+ @tool
96
+ def query_work_items(project: str, wiql: str) -> str:
97
+ """
98
+ Run a WIQL query to find work items.
99
+ Example wiql: "SELECT [id],[title],[state] FROM WorkItems WHERE [System.State] = 'Active'"
100
+ """
101
+ data = az(f'boards query --project {project} --wiql "{wiql}"')
102
+ if isinstance(data, dict) and "error" in data:
103
+ return toon(data, "query_work_items failed")
104
+
105
+ items = [
106
+ {
107
+ "id": i["id"],
108
+ "title": i["fields"]["System.Title"],
109
+ "state": i["fields"]["System.State"],
110
+ "iteration": i["fields"].get("System.IterationPath", "unset"),
111
+ }
112
+ for i in data
113
+ ]
114
+ return toon(items, f"{len(items)} work items in {project}")
115
+
116
+
117
+ @tool
118
+ def link_work_items(source_id: int, target_id: int, link_type: str = "Related") -> str:
119
+ """
120
+ Link two work items.
121
+ link_type options: Related, Parent, Child, Duplicate, Successor, Predecessor
122
+ """
123
+ data = az(
124
+ f"boards work-item relation add --id {source_id} "
125
+ f"--target-id {target_id} --relation-type {link_type}"
126
+ )
127
+ if isinstance(data, dict) and "error" in data:
128
+ return toon(data, "link_work_items failed")
129
+
130
+ return toon(
131
+ {"source": source_id, "target": target_id, "link": link_type},
132
+ f"Linked #{source_id} → #{target_id} as {link_type}"
133
+ )
134
+
135
+
136
+ @tool
137
+ def get_work_item_relations(id: int) -> str:
138
+ """
139
+ Get all linked work items for a given item.
140
+ Use after bulk linking to verify the hierarchy is correct.
141
+ """
142
+ data = az(f"boards work-item show --id {id} --expand relations")
143
+ if "error" in data:
144
+ return toon(data, "get_work_item_relations failed")
145
+
146
+ relations = [
147
+ {
148
+ "related_id": r["url"].split("/")[-1],
149
+ "type": r.get("rel", "unknown"),
150
+ "name": r.get("attributes", {}).get("name", ""),
151
+ }
152
+ for r in data.get("relations", [])
153
+ ]
154
+ return toon(relations, f"{len(relations)} relations for #{id}")
155
+
156
+
157
+ @tool
158
+ def add_comment(id: int, comment: str) -> str:
159
+ """
160
+ Add a discussion comment to a work item.
161
+ Use to log agent reasoning, decisions, or traceability notes.
162
+ """
163
+ data = az(f'boards work-item update --id {id} --discussion "{comment}"')
164
+ if "error" in data:
165
+ return toon(data, "add_comment failed")
166
+
167
+ return toon({"id": id}, f"Comment added to #{id}")
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: az-devops-cli-mcp
3
+ Version: 1.0.2
4
+ Summary: Azure DevOps CLI MCP server for Claude Code and Codex CLI — boards, work items, repos, PRs
5
+ Author: LytStore Team
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/PraiseSinkamba/az-devops-cli-mcp
8
+ Project-URL: Repository, https://github.com/PraiseSinkamba/az-devops-cli-mcp
9
+ Project-URL: Issues, https://github.com/PraiseSinkamba/az-devops-cli-mcp/issues
10
+ Project-URL: Changelog, https://github.com/PraiseSinkamba/az-devops-cli-mcp/blob/main/CHANGELOG.md
11
+ Keywords: azure,devops,mcp,claude,codex,boards,work-items
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: mcp>=1.0.0
24
+ Requires-Dist: python-dotenv>=1.0.0
25
+ Provides-Extra: ag2
26
+ Requires-Dist: pyautogen>=0.2.0; extra == "ag2"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest; extra == "dev"
29
+ Requires-Dist: pytest-mock; extra == "dev"
30
+ Requires-Dist: build; extra == "dev"
31
+ Requires-Dist: twine; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # az-devops-cli-mcp
35
+
36
+ Azure DevOps MCP tools for **Claude Code** and **Codex CLI** — boards, work items, repos, and PRs.
37
+
38
+ [![PyPI version](https://img.shields.io/pypi/v/az-devops-cli-mcp)](https://pypi.org/project/az-devops-cli-mcp/)
39
+ [![CI](https://github.com/PraiseSinkamba/az-devops-cli-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/PraiseSinkamba/az-devops-cli-mcp/actions)
40
+ [![Python 3.10+](https://img.shields.io/pypi/pyversions/az-devops-cli-mcp)](https://pypi.org/project/az-devops-cli-mcp/)
41
+
42
+ ---
43
+
44
+ ## Install & Register
45
+
46
+ ```bash
47
+ # 1. Install
48
+ pip install az-devops-cli-mcp
49
+
50
+ # 2. Login (once per machine)
51
+ az login
52
+ az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
53
+
54
+ # 3. Register with Claude Code
55
+ claude mcp add az-devops-cli-mcp -- python -m az_devops_cli_mcp.server
56
+
57
+ # 4. Register with Codex CLI
58
+ codex mcp add az-devops-cli-mcp -- python -m az_devops_cli_mcp.server
59
+ ```
60
+
61
+ Restart Claude Code or Codex CLI. All 13 tools are ready.
62
+
63
+ ---
64
+
65
+ ## How It Works
66
+
67
+ The server runs as a **stdio process** — spawned on demand by the client, no ports or background services needed.
68
+
69
+ ```
70
+ Claude Code / Codex CLI
71
+ | spawns on demand
72
+ v
73
+ python -m az_devops_cli_mcp.server (stdin/stdout)
74
+ |
75
+ v
76
+ az CLI (your az login session)
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Available Tools (13 total)
82
+
83
+ ### Work Items
84
+ | Tool | What it does |
85
+ |---|---|
86
+ | `create_work_item` | Create Task, Bug, User Story, Epic |
87
+ | `get_work_item` | Get item by ID |
88
+ | `update_work_item` | Update state, assignment, title |
89
+ | `set_iteration` | Assign to sprint (required for Sprint board) |
90
+ | `query_work_items` | WIQL query for bulk lookups |
91
+ | `link_work_items` | Link items (Child, Parent, Related) |
92
+ | `get_work_item_relations` | Verify hierarchy after linking |
93
+ | `add_comment` | Add discussion comment / agent log |
94
+
95
+ ### Repos / PRs
96
+ | Tool | What it does |
97
+ |---|---|
98
+ | `list_prs` | List PRs by status |
99
+ | `get_pr` | Get PR details |
100
+ | `create_pr` | Open a new PR |
101
+ | `link_pr_to_work_item` | Trace PR to work item |
102
+ | `list_branches` | List repo branches |
103
+
104
+ ---
105
+
106
+ ## AG2 Usage (optional)
107
+
108
+ ```python
109
+ from autogen import ConversableAgent
110
+ from az_devops_cli_mcp import ALL_TOOLS, WORK_ITEM_TOOLS, REPO_TOOLS
111
+
112
+ agent = ConversableAgent(
113
+ name="devops_agent",
114
+ tools=ALL_TOOLS,
115
+ system_message="You manage Azure DevOps for LytStore..."
116
+ )
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Configuration
122
+
123
+ | Method | How |
124
+ |---|---|
125
+ | `az devops configure` | Preferred — org set globally for your machine |
126
+ | `AZURE_DEVOPS_ORG` env var | Per-session override |
127
+ | `.env` file | `AZURE_DEVOPS_ORG=https://dev.azure.com/YOUR_ORG` |
128
+
129
+ ---
130
+
131
+ ## Safety
132
+
133
+ Destructive commands (`delete`, `remove`, `destroy`) are blocked at the core layer.
134
+ The agent returns an error and requires explicit user confirmation.
135
+
136
+ ---
137
+
138
+ ## Manual MCP Config (fallback)
139
+
140
+ If `claude mcp add` / `codex mcp add` aren't available, add this to your config file manually.
141
+
142
+ **Claude Code** — `~/.claude/claude_desktop_config.json`:
143
+ ```json
144
+ {
145
+ "mcpServers": {
146
+ "az-devops-cli-mcp": {
147
+ "type": "stdio",
148
+ "command": "python",
149
+ "args": ["-m", "az_devops_cli_mcp.server"]
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ **Codex CLI** — `~/.codex/config.json`: same block.
156
+
157
+ ---
158
+
159
+ ## Contributing
160
+
161
+ ```bash
162
+ git clone https://github.com/PraiseSinkamba/az-devops-cli-mcp
163
+ cd az-devops-cli-mcp
164
+ pip install -e ".[dev]"
165
+ pytest tests/
166
+ ```
@@ -0,0 +1,14 @@
1
+ az_devops_cli_mcp/__init__.py,sha256=O9YpBOuEyLHnJg3IeIlXJAxiZOgY48iQczu-07oWb-E,970
2
+ az_devops_cli_mcp/__main__.py,sha256=dL_h8taJRfgwf3Wg2EvkcVyEPg-qkjV_Vsajf47RbNg,148
3
+ az_devops_cli_mcp/config.py,sha256=w8vvVeaXfHILH4saHSdfQT6lEoRkfxf553Bmzy0EIXE,1453
4
+ az_devops_cli_mcp/core.py,sha256=BtXuu3hEqpxrXarP-xDi56Ajutdnk1BSUWRZ2OYtI8E,2163
5
+ az_devops_cli_mcp/server.py,sha256=L4CNc0KvqB8ebFfsROskndlF_Fq84VBwxCKbD8wpzhc,15710
6
+ az_devops_cli_mcp/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ az_devops_cli_mcp/tools/repos.py,sha256=o61WFWxPdtmxQzVoGeo3EXLyJmAps0E_zAOhXEnlSy8,3601
8
+ az_devops_cli_mcp/tools/work_items.py,sha256=eXneZ0nJEKKwPCiddztw2JOb_2Pkn02JBzD6MVciGSY,5189
9
+ az_devops_cli_mcp-1.0.2.dist-info/licenses/LICENSE,sha256=_Z_WBVAdZTY5Z0yzT1Mf3qVSKIxA6noMXAqIQV2MUiE,1070
10
+ az_devops_cli_mcp-1.0.2.dist-info/METADATA,sha256=dzKDugBi9FPzwaeOViEkYf50Ke5GXRXM4SIECoqLnGU,4767
11
+ az_devops_cli_mcp-1.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ az_devops_cli_mcp-1.0.2.dist-info/entry_points.txt,sha256=OpWq14Y0HfzvBSGFFF5QWGLXJ0dCr6UJUsB46xElL-Q,67
13
+ az_devops_cli_mcp-1.0.2.dist-info/top_level.txt,sha256=jYBy896CSOuTcpDHiZ_W9BMO1Z_1hOIjkNFPH6NZT84,18
14
+ az_devops_cli_mcp-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ az-devops-cli-mcp = az_devops_cli_mcp.server:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 LytStore Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ az_devops_cli_mcp