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

AZURE_DEVOPS_ORG_URL

yes

Cloud https://dev.azure.com/{org} or on-prem https://server/tfs/{collection}

AZURE_DEVOPS_TOKEN

yes

Bearer token or PAT

AZURE_DEVOPS_AUTH_SCHEME

no

bearer (default) sends Authorization: Bearer; pat sends Authorization: Basic base64(":"+token)

AZURE_DEVOPS_API_VERSION

no

Default 7.1; lower it for older on-prem servers

DEVOPS_UTILS_SKIP_CONFIRMATION

no

Truthy (1/true/yes/on) bypasses the MCP write confirmation and the CLI’s confirmation prompt (see Human-in-the-loop)

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_* (run devops-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.

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 a relations list 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) adds rev and a fields map of the raw work item, keyed by reference name — descriptions (System.Description, HTML), scheduling dates (Microsoft.VSTS.Scheduling.*), and Custom.* fields all live there. Composes with --relations. list/search stay trimmed: find ids there, then --full a 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)

  1. Repo names: azdo repos [--project P] [--name SUBSTR] / azdo_list_repositories(project, name_filter=...).

  2. File paths: azdo files --project P --repo R --pattern '*.yml' / azdo_find_repo_files — Git Items API glob, works on cloud and on-prem.

  3. 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_definitions lists 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_build fetches one. Short branch names are expanded to refs/heads/....

  • Timeline: azdo timeline <build-id> --project P / azdo_get_build_timeline returns the run’s stages/jobs/tasks with state, result, error/warning issues, and each step’s log_id — the first stop when diagnosing a failed run.

  • Logs: azdo logs <build-id> --project P / azdo_list_build_logs lists {id, line_count}; azdo log <build-id> <log-id> --project P [--start-line N] [--end-line N] / azdo_get_build_log prints 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> — no project/repo needed (the artifact URI carries only the build id).

  • Tag: azdo build-tag <build-id> TAG [TAG ...] --project P / azdo_tag_build adds 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).