Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,8 @@ tools:

# With scoping options
azure-devops:
toolsets: [repos, wit]
version: 2.8.1 # Optional exact-semver override
toolsets: [repositories, work-items]
allowed: [wit_get_work_item, repo_list_repos_by_project]
org: myorg # Optional — inferred from git remote by default

Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe

- `catalog [--kind <safe-outputs|runtimes|tools|engines|models|versions|ado-proxy>] [--json]` - List the compiler's in-tree registries for scripting or discovery.
- `--kind <...>` - Restrict output to one category. When omitted, emits every category.
- `--kind versions` - Emit the compiler's pinned **semver** versions (`copilot_cli`, `awf`, `mcpg`) as a single source of truth. CI reads these deterministically instead of scraping the Rust source, e.g. `ado-aw catalog --kind versions --json | jq -r '.versions.copilot_cli'`.
- `--kind versions` - Emit the compiler's pinned **semver** versions (`copilot_cli`, `awf`, `mcpg`, `ado_mcp`) as a single source of truth. `ado_mcp` is the compiler default; a workflow may override it with `tools.azure-devops.version`. CI reads these defaults deterministically instead of scraping the Rust source, e.g. `ado-aw catalog --kind versions --json | jq -r '.versions.copilot_cli'`.
- `--kind ado-proxy` - Emit the versioned deny-by-default Stage 1 ADO read-policy catalog and whether its credential-isolated runtime is available.
- `--json` - Emit the catalog as structured JSON instead of the human-readable report.

Expand Down
3 changes: 2 additions & 1 deletion docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ tools: # optional tool configuration
# allowed-extensions: [.md, .json]
azure-devops: true # first-class ADO MCP integration (see docs/tools.md)
# azure-devops: # Alternative object format (with scoping)
# toolsets: [repos, wit]
# version: "2.8.1" # Optional exact-semver override; defaults to compiler pin
# toolsets: [repositories, work-items]
# allowed: [wit_get_work_item]
# org: myorg
runtimes: # optional runtime configuration (language environments)
Expand Down
14 changes: 10 additions & 4 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,18 @@ tools:
# With scoping options
tools:
azure-devops:
toolsets: [repos, wit, core] # ADO API toolset groups
version: 2.8.1 # Optional exact-semver override
toolsets: [repositories, work-items, core] # MCP-native toolset groups
allowed: [wit_get_work_item, core_list_projects] # Explicit tool allow-list
org: myorg # Optional override (inferred from git remote)
```

When enabled, the compiler:
- Requires `permissions.read` as the trusted proxy's token source
- Installs the pinned `@azure-devops/mcp` package on the runner and mounts it
read-only into an unchanged `node:20-slim` container; the isolated container
needs no npm registry access
- Installs `@azure-devops/mcp` on the runner at the compiler-pinned version
(or the exact semantic version set with `version:`) and mounts it read-only
into an unchanged `node:20-slim` container; the isolated container needs no
npm registry access
- Runs that container on an internal network with `dev.azure.com` redirected
to `ado-proxy` and a public interception CA trusted only by that process
- Gives the MCP a non-secret sentinel in `ADO_MCP_AUTH_TOKEN`; the real token
Expand All @@ -93,6 +95,10 @@ When enabled, the compiler:
- Auto-infers org from the git remote URL at compile time (overridable via `org:` field)
- Fails compilation if org cannot be determined (no explicit override and no ADO git remote)

`toolsets` and `allowed` are passed through to the selected Azure DevOps MCP
version. Use that package version's native domain and tool names; ado-aw does
not translate or validate the upstream MCP contract.

The generated `az` wrapper similarly carries only a sentinel PAT and routes
Azure DevOps traffic through the proxy. Catalogued reads (`az devops`,
`az repos`, `az pipelines`, `az boards`, and `az rest`) work without signing
Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/guides/creating-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Enable the built-in Azure DevOps MCP integration:
```yaml
tools:
azure-devops:
toolsets: [core, repos, wit]
toolsets: [core, repositories, work-items]
allowed:
- core_list_projects
- repo_list_repos_by_project
Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/guides/using-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ name: "triage-agent"
description: "Triages work items and proposes follow-up actions"
tools:
azure-devops:
toolsets: [core, wit]
toolsets: [core, work-items]
allowed:
- core_list_projects
- wit_get_work_item
Expand Down
3 changes: 2 additions & 1 deletion site/src/content/docs/reference/front-matter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ tools: # optional tool configuration
# allowed-extensions: [.md, .json]
azure-devops: true # first-class ADO MCP integration (see docs/tools.md)
# azure-devops: # Alternative object format (with scoping)
# toolsets: [repos, wit]
# version: "2.8.1" # Optional exact-semver override; defaults to compiler pin
# toolsets: [repositories, work-items]
# allowed: [wit_get_work_item]
# org: myorg
runtimes: # optional runtime configuration (language environments)
Expand Down
17 changes: 12 additions & 5 deletions site/src/content/docs/reference/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ During Stage 3 execution, memory files are validated (path safety, extension fil

### Azure DevOps MCP (`azure-devops:`)

First-class Azure DevOps MCP integration. Auto-configures the ADO MCP container, token mapping, [MCPG](/ado-aw/reference/mcpg/) entry, and [network allowlist](/ado-aw/reference/network/).
First-class Azure DevOps MCP integration. Auto-configures the ADO MCP
container, credential-isolated policy proxy, and
[MCPG](/ado-aw/reference/mcpg/) entry.

```yaml
# Simple enablement (auto-infers org from git remote)
Expand All @@ -77,18 +79,23 @@ tools:
# With scoping options
tools:
azure-devops:
toolsets: [repos, wit, core] # ADO API toolset groups
version: 2.8.1 # Optional exact-semver override
toolsets: [repositories, work-items, core] # MCP-native toolset groups
allowed: [wit_get_work_item, core_list_projects] # Explicit tool allow-list
org: myorg # Optional override (inferred from git remote)
```

When enabled, the compiler:
- Generates a containerized stdio MCP entry (`node:20-slim` + `npx @azure-devops/mcp`) in the [MCPG](/ado-aw/reference/mcpg/) config
- Auto-maps `AZURE_DEVOPS_EXT_PAT` token passthrough when [`permissions.read`](/ado-aw/reference/network/#permissions-ado-access-tokens) is configured
- Adds ADO-specific hosts to the [network allowlist](/ado-aw/reference/network/)
- Requires [`permissions.read`](/ado-aw/reference/network/#permissions-ado-access-tokens) as the trusted proxy's token source
- Installs `@azure-devops/mcp` at the compiler default or exact `version:` override and mounts it read-only into `node:20-slim`
- Gives the MCP a non-secret sentinel; the real Azure DevOps token exists only in the policy proxy
- Auto-infers org from the git remote URL at compile time (overridable via `org:` field)
- Fails compilation if org cannot be determined (no explicit override and no ADO git remote)

`toolsets` and `allowed` pass through to the selected package version. Use its
native domain and tool names; ado-aw does not translate or validate that
upstream contract.

## Built-in CLIs

Two CLI tools are always available inside the agent's bash environment without opting in — no `tools.bash:` entry required. This mirrors gh-aw's "the runner has `gh`" assumption: the host is presumed to have each binary pre-installed.
Expand Down
25 changes: 17 additions & 8 deletions src/compile/agentic_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ use std::path::Path;

use super::common::PerJobPools;
use super::common::{
self, ADO_BUILD_ID_SUFFIX, ADO_MCP_HOST_NODE_MODULES, ADO_MCP_PACKAGE, ADO_MCP_VERSION,
self, ADO_BUILD_ID_SUFFIX, ADO_MCP_HOST_NODE_MODULES, ADO_MCP_PACKAGE,
ADO_PROXY_CONTAINER_NAME, ADO_PROXY_IMAGE, ADO_PROXY_LISTEN_PORT, ADO_PROXY_NETWORK_NAME,
ADO_PROXY_PUBLIC_CA_HOST_PATH, ADO_PROXY_TLS_PORT, AWF_SQUID_URL, AWF_VERSION, AZ_WRAPPER_DIR,
HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref,
Expand Down Expand Up @@ -1144,7 +1144,9 @@ fn build_agent_job(
if ado_proxy_enabled {
steps.push(Step::Bash(prepare_ado_proxy_network_step()));
if common::ado_mcp_enabled(front_matter) {
steps.push(Step::Bash(prepare_ado_mcp_step()));
steps.push(Step::Bash(prepare_ado_mcp_step(
common::ado_mcp_version(front_matter),
)));
}
steps.push(Step::Bash(start_ado_proxy_step(front_matter)));
}
Expand Down Expand Up @@ -4126,7 +4128,7 @@ fn prepare_ado_proxy_network_step() -> BashStep {
/// read-only into a container that does not. The mount point is load-bearing:
/// Node resolves dependencies by walking upward from the importing file, so
/// the tree must land at `/app/node_modules`.
fn prepare_ado_mcp_step() -> BashStep {
fn prepare_ado_mcp_step(version: &str) -> BashStep {
let script = format!(
"set -euo pipefail\n\
\n\
Expand All @@ -4139,16 +4141,16 @@ fn prepare_ado_mcp_step() -> BashStep {
cd \"$MCP_STAGE\"\n\
npm init -y >/dev/null 2>&1\n\
npm install --omit=dev --no-audit --no-fund --save-exact \\\n \
\"{ADO_MCP_PACKAGE}@{ADO_MCP_VERSION}\"\n\
\"{ADO_MCP_PACKAGE}@{version}\"\n\
\n\
# Verify the pin actually took. `npm install` resolves a *range* for\n\
# anything it also has to satisfy transitively, so a matching request\n\
# does not by itself guarantee a matching tree — and the agent's tool\n\
# surface is defined by whatever ends up on disk here.\n\
MCP_INSTALLED=$(node -p \\\n \
\"require('{ADO_MCP_HOST_NODE_MODULES}/{ADO_MCP_PACKAGE}/package.json').version\")\n\
if [ \"$MCP_INSTALLED\" != \"{ADO_MCP_VERSION}\" ]; then\n \
echo \"##vso[task.complete result=Failed]Azure DevOps MCP resolved to $MCP_INSTALLED, expected {ADO_MCP_VERSION}\"\n \
if [ \"$MCP_INSTALLED\" != \"{version}\" ]; then\n \
echo \"##vso[task.complete result=Failed]Azure DevOps MCP resolved to $MCP_INSTALLED, expected {version}\"\n \
exit 1\n\
fi\n\
\n\
Expand Down Expand Up @@ -5765,9 +5767,9 @@ safe-outputs:
assert!(network_script.contains(&format!(
"docker network create --internal {ADO_PROXY_NETWORK_NAME}"
)));
let script = prepare_ado_mcp_step().script;
let script = prepare_ado_mcp_step(common::ADO_MCP_VERSION).script;
assert!(
script.contains(&format!("{ADO_MCP_PACKAGE}@{ADO_MCP_VERSION}")),
script.contains(&format!("{ADO_MCP_PACKAGE}@{}", common::ADO_MCP_VERSION)),
"the MCP package must be pinned, not floating: {script}"
);
assert!(
Expand All @@ -5778,6 +5780,13 @@ safe-outputs:
script.contains("$MCP_INSTALLED\" != \"") ,
"the resolved version must be verified, not just requested: {script}"
);

let override_script = prepare_ado_mcp_step("2.9.0").script;
assert!(override_script.contains(&format!("{ADO_MCP_PACKAGE}@2.9.0")));
assert!(override_script.contains("expected 2.9.0"));
assert!(
!override_script.contains(&format!("{ADO_MCP_PACKAGE}@{}", common::ADO_MCP_VERSION))
);
}

#[test]
Expand Down
37 changes: 33 additions & 4 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ fn test_ado_proxy_activation_follows_permissions_read_not_mcp_tool() {
}
}

#[test]
fn test_ado_mcp_version_uses_override_or_compiler_default() {
let (defaulted, _) = parse_markdown(
"---\nname: t\ndescription: x\ntools:\n azure-devops: true\n---\n",
)
.unwrap();
assert_eq!(ado_mcp_version(&defaulted), ADO_MCP_VERSION);

let (overridden, _) = parse_markdown(
"---\nname: t\ndescription: x\ntools:\n azure-devops:\n version: 2.9.0\n---\n",
)
.unwrap();
assert_eq!(ado_mcp_version(&overridden), "2.9.0");
}

/// Returns the directory in which the atomic tempfile should be created for a
/// write to `path`. The tempfile must live on the same filesystem as `path`
/// so that the final `persist()` rename is atomic (EXDEV guard).
Expand Down Expand Up @@ -1840,6 +1855,20 @@ pub fn ado_mcp_enabled(front_matter: &FrontMatter) -> bool {
.is_some_and(crate::compile::types::AzureDevOpsToolConfig::is_enabled)
}

/// Effective Azure DevOps MCP package version for this workflow.
///
/// Workflows may override the compiler pin with an exact semantic version.
/// The compiler-owned default remains deterministic and is exposed by
/// `ado-aw catalog --kind versions`.
pub fn ado_mcp_version(front_matter: &FrontMatter) -> &str {
front_matter
.tools
.as_ref()
.and_then(|tools| tools.azure_devops.as_ref())
.and_then(crate::compile::types::AzureDevOpsToolConfig::version)
.unwrap_or(ADO_MCP_VERSION)
}

/// Directory the generated `az` wrapper is installed into inside the sandbox.
///
/// Separate from the ado-script bundle directory because it is prepended to
Expand Down Expand Up @@ -7215,7 +7244,7 @@ safe-outputs:
#[test]
fn test_ado_tool_with_toolsets() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\ntools:\n azure-devops:\n toolsets: [repos, wit, core]\n---\n",
"---\nname: test\ndescription: test\ntools:\n azure-devops:\n toolsets: [repositories, work-items, core]\n---\n",
)
.unwrap();
let extensions = collect_extensions(&fm);
Expand All @@ -7225,8 +7254,8 @@ safe-outputs:
let ado = config.mcp_servers.get("azure-devops").unwrap();
let args = ado.entrypoint_args.as_ref().unwrap();
assert!(args.contains(&"-d".to_string()));
assert!(args.contains(&"repos".to_string()));
assert!(args.contains(&"wit".to_string()));
assert!(args.contains(&"repositories".to_string()));
assert!(args.contains(&"work-items".to_string()));
assert!(args.contains(&"core".to_string()));
}

Expand Down Expand Up @@ -7301,7 +7330,7 @@ safe-outputs:
#[test]
fn test_ado_tool_invalid_toolset_fails() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\ntools:\n azure-devops:\n org: myorg\n toolsets: [\"repos\", \"bad toolset\"]\n---\n",
"---\nname: test\ndescription: test\ntools:\n azure-devops:\n org: myorg\n toolsets: [\"repositories\", \"bad toolset\"]\n---\n",
)
.unwrap();
let extensions = collect_extensions(&fm);
Expand Down
11 changes: 7 additions & 4 deletions src/compile/imports/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,7 @@ mod tests {
#[test]
fn tools_union_allow_arrays_and_consumer_scalars_win() {
let mut consumer = ymap(
"tools:\n edit: false\n azure-devops:\n allowed: [b, consumer]\n org: consumer",
"tools:\n edit: false\n azure-devops:\n version: 3.0.0\n allowed: [b, consumer]\n org: consumer",
);
merge_resolved(
&mut consumer,
Expand All @@ -792,17 +792,19 @@ mod tests {
r#"tools:
edit: true
azure-devops:
version: 2.8.1
allowed: [a, b]
toolsets: [repos]
toolsets: [repositories]
org: first"#,
"",
),
local(
r#"tools:
edit: true
azure-devops:
version: 2.9.0
allowed: [b, c]
toolsets: [wit]
toolsets: [work-items]
org: second"#,
"",
),
Expand All @@ -811,13 +813,14 @@ mod tests {
.unwrap();
assert_eq!(consumer["tools"]["edit"], false);
assert_eq!(consumer["tools"]["azure-devops"]["org"], "consumer");
assert_eq!(consumer["tools"]["azure-devops"]["version"], "3.0.0");
assert_eq!(
consumer["tools"]["azure-devops"]["allowed"],
serde_yaml::from_str::<Value>("[a, b, c, consumer]").unwrap()
);
assert_eq!(
consumer["tools"]["azure-devops"]["toolsets"],
serde_yaml::from_str::<Value>("[repos, wit]").unwrap()
serde_yaml::from_str::<Value>("[repositories, work-items]").unwrap()
);
}

Expand Down
Loading
Loading