Azure DevOps work-item tools
A limited, LLM-friendly interface to Azure DevOps work items, working against both
Services (cloud) and Server (on-prem). Requires the azure extra (httpx).
Configuration (no machine credentials)
Credentials are never read from the machine (no az CLI, credential files, or
Windows credential store). Everything comes from environment variables —
set directly, or loaded from the first existing env file of
$DEVOPS_UTILS_ENV_FILE, ./.env.devops-utils, ~/.devops-utils.env (real
environment variables win; see the install guide):
Variable |
Required |
Notes |
|---|---|---|
|
yes |
Cloud |
|
yes |
Bearer token or PAT |
|
no |
|
|
no |
Default |
|
no |
Truthy ( |
Human-in-the-loop (MCP writes)
On the MCP server, all ten write tools (azdo_create_work_item,
azdo_comment_work_item, azdo_set_work_item_tags, azdo_update_work_item,
azdo_add_work_item_link, azdo_remove_work_item_link,
azdo_add_work_item_attachment, azdo_tag_build, azdo_comment_pull_request,
azdo_apply_plan)
require human approval via MCP elicitation before mutating Azure DevOps —
the tool describes the pending change and applies it only on accept;
decline/cancel returns a cancelled status and writes nothing. When the
client can’t prompt (elicitation unsupported / non-interactive), the write is
blocked unless DEVOPS_UTILS_SKIP_CONFIRMATION is truthy, which allows
unattended automation. Read tools are not gated. Implemented in
src/devops_utils/mcp/server.py (_confirm_write + WRITE_TOOLS).
Human-in-the-loop (CLI writes)
The CLI’s write commands (create, comment, tag, update, link,
unlink, build-tag, pr-comment, attach, apply) print a preview of the
pending change and prompt before calling Azure DevOps — apply (bulk plan)
previews every operation in the batch and asks a single
Apply all N operation(s)? confirmation.
Pass --yes/-y to skip the prompt, or --dry-run to print the preview and
exit without applying anything. DEVOPS_UTILS_SKIP_CONFIRMATION also skips
the prompt, for scripted use. Implemented in
src/devops_utils/cli/commands/azdo.py (_confirm_or_dry_run) and shared
with the MCP server via src/devops_utils/core/confirmation.py.
The preview, the (dry run — not applied) marker, and the prompt all go to
stderr, so stdout carries nothing but the command’s JSON result and
azdo create … --yes -o id (or | jq .id) works.
With --dry-run, or when the prompt is
declined, stdout is empty and the exit code is 0.
Surfaces
The same operations are exposed three ways, all reading the env vars above:
CLI:
devops-utils azdo {repos,files,code-search,list,search,get,create,update,comment,tag,link,unlink,attach,apply,definitions,builds,build,timeline,logs,log,build-tag,pr-comment}MCP tools:
azdo_*(rundevops-utils-mcp)Agent callables:
devops_utils.agent.tools.azdo_*
Core logic lives in src/devops_utils/core/azure_devops/ (client.py,
workitems.py, repos.py, builds.py, pullrequests.py, bulk.py) and is
surface-agnostic.
Nothing has to be installed to use the CLI or the MCP server — with uv
present, uvx resolves the package and its extra per invocation:
uvx --from "devops-utils[azure]" devops-utils azdo … and
uvx --from "devops-utils[mcp]" devops-utils-mcp. The extra is mandatory
([azure] for azdo, [mcp] for the server, [all] for everything);
uv tool install "devops-utils[all]" puts the bare commands on PATH.
References (link / azdo_add_work_item_link)
One entry point covers every reference kind:
kind |
needs |
|
relation |
|---|---|---|---|
|
project + repo |
commit SHA |
|
|
project + repo |
PR id |
|
|
project + repo |
branch name |
|
|
— |
build id |
|
|
— |
target work-item id |
|
|
— |
target work-item id |
|
|
— |
target work-item id |
|
|
— |
target work-item id |
|
|
— |
target work-item id |
|
|
— |
raw URL |
|
unlink (azdo unlink / azdo_remove_work_item_link) removes a reference
using the same kind/value pairs — e.g. re-parenting is
unlink --kind parent --value <old> followed by link --kind parent --value <new>.
The relation is located by rel type + URL and removed by index with a guarding
JSON-Patch test op.
Hierarchy (parent/child)
Create under a parent:
azdo create --parent <id>/azdo_create_work_item(..., parent=<id>)links the new item under an existing one (e.g. a Task under a User Story) in the same API call.Read relations:
azdo get <id> --relations/azdo_get_work_item(id, relations=True)returns arelationslist of{kind, target, ...}dicts — work-item kinds (parent,child,predecessor,successor,work_item) carry the target work-item id;hyperlink/attachment/artifact kinds carry the URL.Read every field:
azdo get <id> --full/azdo_get_work_item(id, full=True)addsrevand afieldsmap of the raw work item, keyed by reference name — descriptions (System.Description, HTML), scheduling dates (Microsoft.VSTS.Scheduling.*), andCustom.*fields all live there. Composes with--relations.list/searchstay trimmed: find ids there, then--fulla single item.
update (azdo update / azdo_update_work_item) changes System.State,
System.AssignedTo, System.Title, System.Description, System.AreaPath,
and/or System.IterationPath on an existing item — state names are
process-template-specific (Closed/Done/Resolved).
Area path / iteration path
create, update, list, and search all take --area-path /
--iteration-path (area_path / iteration_path on the azdo_* tools).
On create/update they set System.AreaPath/System.IterationPath
directly. On list/search they filter, using WIQL UNDER — matching the
given node and everything nested under it in the Azure Boards area/iteration
tree, e.g. --area-path 'MyProject\Team A' also matches
MyProject\Team A\Sub-team.
Custom fields
create and update (CLI and azdo_* tools) also take arbitrary process- or
org-specific fields via repeatable --field NAME=VALUE (CLI) or a fields
dict (azdo_create_work_item/azdo_update_work_item), e.g.
--field Custom.RiskLevel=High or fields={"Custom.RiskLevel": "High"}. Use
the field’s reference name, not its display label — reference names are
process/org specific, so check the target project’s process configuration.
fields is applied after the named args, so a key that targets e.g.
System.AreaPath wins over --area-path.
Read them back with azdo get <id> --full — custom fields used to be
write-only, which made “did that patch land?” unanswerable without hitting REST
by hand.
The CLI’s --field NAME=VALUE always yields a string; the fields dict on
the azdo_* tools preserves real JSON types, which is the path to use when a
numeric/boolean write is rejected.
Scheduling fields (start / target / due date, effort)
There is no named parameter for dates or estimates — they are ordinary fields,
set through fields / --field by reference name:
Microsoft.VSTS.Scheduling.StartDate, .TargetDate (the pair Delivery Plans
render, normally on Epic/Feature), .DueDate, .FinishDate, .Effort,
.StoryPoints, .OriginalEstimate, .RemainingWork, .CompletedWork, plus
Microsoft.VSTS.Common.Priority/.Severity/.BusinessValue. Values are ISO
8601 (2026-08-03 or 2026-08-03T00:00:00Z, stored UTC).
devops-utils azdo update 1400 \
--field Microsoft.VSTS.Scheduling.StartDate=2026-08-03 \
--field Microsoft.VSTS.Scheduling.TargetDate=2026-09-30
Which fields a type has is process-template-specific; an absent one fails the
patch with an unknown-field error. create/update return the trimmed shape,
which drops every Microsoft.VSTS.* key — confirm with azdo get <id> --full.
Work-item research filters
list/search (and their azdo_* counterparts) filter by state, type,
assignee, area/iteration path, and tags (repeatable --tag, AND
semantics). --mine / assigned_to="@Me" uses the WIQL @Me macro — the
server resolves the identity behind the token, so “assigned to me” needs no
configured email. “Pending” work is the non-closed states of the process
template (e.g. --state New --state Active).
Repo search (three tiers)
Repo names:
azdo repos [--project P] [--name SUBSTR]/azdo_list_repositories(project, name_filter=...).File paths:
azdo files --project P --repo R --pattern '*.yml'/azdo_find_repo_files— Git Items API glob, works on cloud and on-prem.Code content:
azdo code-search "TEXT" --project P [--repo R]/azdo_code_search— Search extension (cloud always; the almsearch host is derived automatically). On-prem servers without it get a clear error; fall back to tier 2.
Builds
Definitions:
azdo definitions --project P [--name 'CI*']/azdo_list_build_definitionslists pipelines as{id, name, path, type, queue_status, web_url}.Query:
azdo builds --project P [--definition ID] [--branch B] [--status S] [--result R] [--top N]/azdo_list_builds(project, ...)lists builds (newest first) as trimmed{id, number, definition, status, result, branch, requested_for, queue_time, finish_time, web_url}dicts;azdo build <id> --project P/azdo_get_buildfetches one. Short branch names are expanded torefs/heads/....Timeline:
azdo timeline <build-id> --project P/azdo_get_build_timelinereturns the run’s stages/jobs/tasks withstate,result, error/warningissues, and each step’slog_id— the first stop when diagnosing a failed run.Logs:
azdo logs <build-id> --project P/azdo_list_build_logslists{id, line_count};azdo log <build-id> <log-id> --project P [--start-line N] [--end-line N]/azdo_get_build_logprints content. Tail big logs (start_line = line_count - 200) instead of fetching whole.Link to a work item:
azdo link <wi> --kind build --value <build-id>— noproject/reponeeded (the artifact URI carries only the build id).Tag:
azdo build-tag <build-id> TAG [TAG ...] --project P/azdo_tag_buildadds tags (builds have no comments; tags are the annotation) and returns the resulting tag list.
Pull-request comments
azdo pr-comment <pr-id> "TEXT" --project P --repo R [--thread N] /
azdo_comment_pull_request posts a new (active) comment thread on a PR, or a
reply when a thread id is given. Returns {thread_id, comment_id, status}.
Commits cannot be commented on — Azure DevOps has no documented REST endpoint for commit comments (the web UI uses an internal API). Comment on the PR containing the commit, or on the work item that links it.
Comments use the System.History field and search uses WIQL CONTAINS, both for
maximum on-prem compatibility (no preview-only endpoints or separate Search host).