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.
- az_devops_cli_mcp/__init__.py +54 -0
- az_devops_cli_mcp/__main__.py +6 -0
- az_devops_cli_mcp/config.py +50 -0
- az_devops_cli_mcp/core.py +76 -0
- az_devops_cli_mcp/server.py +351 -0
- az_devops_cli_mcp/tools/__init__.py +0 -0
- az_devops_cli_mcp/tools/repos.py +124 -0
- az_devops_cli_mcp/tools/work_items.py +167 -0
- az_devops_cli_mcp-1.0.2.dist-info/METADATA +166 -0
- az_devops_cli_mcp-1.0.2.dist-info/RECORD +14 -0
- az_devops_cli_mcp-1.0.2.dist-info/WHEEL +5 -0
- az_devops_cli_mcp-1.0.2.dist-info/entry_points.txt +2 -0
- az_devops_cli_mcp-1.0.2.dist-info/licenses/LICENSE +21 -0
- az_devops_cli_mcp-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/az-devops-cli-mcp/)
|
|
39
|
+
[](https://github.com/PraiseSinkamba/az-devops-cli-mcp/actions)
|
|
40
|
+
[](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,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
|