diff --git a/README.md b/README.md index df0c0752..4ea4ba0d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index 41406d84..29bee84f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -176,7 +176,7 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `catalog [--kind ] [--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. diff --git a/docs/front-matter.md b/docs/front-matter.md index 8a3fd1e6..2f4c1368 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -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) diff --git a/docs/tools.md b/docs/tools.md index f818c91c..cc3b607d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -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 @@ -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 diff --git a/site/src/content/docs/guides/creating-agents.mdx b/site/src/content/docs/guides/creating-agents.mdx index 768fea40..e313437f 100644 --- a/site/src/content/docs/guides/creating-agents.mdx +++ b/site/src/content/docs/guides/creating-agents.mdx @@ -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 diff --git a/site/src/content/docs/guides/using-mcp.mdx b/site/src/content/docs/guides/using-mcp.mdx index b4069e69..00f4e141 100644 --- a/site/src/content/docs/guides/using-mcp.mdx +++ b/site/src/content/docs/guides/using-mcp.mdx @@ -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 diff --git a/site/src/content/docs/reference/front-matter.mdx b/site/src/content/docs/reference/front-matter.mdx index 9920e6c4..bf2d62d8 100644 --- a/site/src/content/docs/reference/front-matter.mdx +++ b/site/src/content/docs/reference/front-matter.mdx @@ -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) diff --git a/site/src/content/docs/reference/tools.mdx b/site/src/content/docs/reference/tools.mdx index db5747b3..d87344e3 100644 --- a/site/src/content/docs/reference/tools.mdx +++ b/site/src/content/docs/reference/tools.mdx @@ -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) @@ -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. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 9abbb39e..7960ca0d 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -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, @@ -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))); } @@ -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\ @@ -4139,7 +4141,7 @@ 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\ @@ -4147,8 +4149,8 @@ fn prepare_ado_mcp_step() -> BashStep { # 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\ @@ -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!( @@ -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] diff --git a/src/compile/common.rs b/src/compile/common.rs index fd8adf8b..c536b428 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -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). @@ -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 @@ -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); @@ -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())); } @@ -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); diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index 11a50432..d439a03a 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -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, @@ -792,8 +792,9 @@ mod tests { r#"tools: edit: true azure-devops: + version: 2.8.1 allowed: [a, b] - toolsets: [repos] + toolsets: [repositories] org: first"#, "", ), @@ -801,8 +802,9 @@ mod tests { r#"tools: edit: true azure-devops: + version: 2.9.0 allowed: [b, c] - toolsets: [wit] + toolsets: [work-items] org: second"#, "", ), @@ -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::("[a, b, c, consumer]").unwrap() ); assert_eq!( consumer["tools"]["azure-devops"]["toolsets"], - serde_yaml::from_str::("[repos, wit]").unwrap() + serde_yaml::from_str::("[repositories, work-items]").unwrap() ); } diff --git a/src/compile/types.rs b/src/compile/types.rs index 6ec4ce24..0bb4a14d 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -3,6 +3,7 @@ //! This module defines the front matter grammar that is shared across all compile targets. use crate::sanitize::SanitizeConfig as SanitizeConfigTrait; +use crate::secure::SemanticVersion; use ado_aw_derive::SanitizeConfig; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -1058,7 +1059,8 @@ impl ProviderConfig { /// cache-memory: /// allowed-extensions: [.md, .json] /// azure-devops: -/// toolsets: [repos, wit] +/// version: 2.8.1 +/// toolsets: [repositories, work-items] /// allowed: [wit_get_work_item] /// ``` #[derive(Debug, Deserialize, Clone, Default)] @@ -1163,7 +1165,8 @@ pub struct CacheMemoryOptions { /// /// # With scoping options /// azure-devops: -/// toolsets: [repos, wit, core] +/// version: 2.8.1 +/// toolsets: [repositories, work-items, core] /// allowed: [wit_get_work_item, wit_my_work_items] /// org: myorg /// ``` @@ -1185,7 +1188,7 @@ impl AzureDevOpsToolConfig { } } - /// Get the ADO API toolset groups to enable (e.g., repos, wit, core) + /// Get the ADO MCP toolset groups to enable. pub fn toolsets(&self) -> &[String] { match self { AzureDevOpsToolConfig::Enabled(_) => &[], @@ -1208,6 +1211,16 @@ impl AzureDevOpsToolConfig { AzureDevOpsToolConfig::WithOptions(opts) => opts.org.as_deref(), } } + + /// Get the exact Azure DevOps MCP package version override. + pub fn version(&self) -> Option<&str> { + match self { + AzureDevOpsToolConfig::Enabled(_) => None, + AzureDevOpsToolConfig::WithOptions(opts) => { + opts.version.as_ref().map(SemanticVersion::as_str) + } + } + } } impl SanitizeConfigTrait for AzureDevOpsToolConfig { @@ -1222,7 +1235,11 @@ impl SanitizeConfigTrait for AzureDevOpsToolConfig { /// Azure DevOps MCP options #[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] pub struct AzureDevOpsOptions { - /// ADO API toolset groups to enable (e.g., repos, wit, core, work-items) + /// Exact Azure DevOps MCP package version override. + /// Defaults to the compiler-pinned version. + #[serde(default)] + pub version: Option, + /// ADO MCP toolset groups to enable (e.g., repositories, work-items, core). /// Passed as `-d` flags to the ADO MCP entrypoint. #[serde(default)] pub toolsets: Vec, @@ -5811,6 +5828,7 @@ Body assert!(ado.toolsets().is_empty()); assert!(ado.allowed().is_empty()); assert!(ado.org().is_none()); + assert!(ado.version().is_none()); } #[test] @@ -5820,7 +5838,8 @@ name: "Test" description: "Test" tools: azure-devops: - toolsets: [repos, wit, core] + version: 2.9.0 + toolsets: [repositories, work-items, core] allowed: [wit_get_work_item, core_list_projects] org: myorg --- @@ -5830,9 +5849,10 @@ Body let (fm, _) = super::super::common::parse_markdown(content).unwrap(); let ado = fm.tools.as_ref().unwrap().azure_devops.as_ref().unwrap(); assert!(ado.is_enabled()); - assert_eq!(ado.toolsets(), &["repos", "wit", "core"]); + assert_eq!(ado.toolsets(), &["repositories", "work-items", "core"]); assert_eq!(ado.allowed(), &["wit_get_work_item", "core_list_projects"]); assert_eq!(ado.org(), Some("myorg")); + assert_eq!(ado.version(), Some("2.9.0")); } #[test] @@ -5842,7 +5862,7 @@ name: "Test" description: "Test" tools: azure-devops: - toolsets: [wit] + toolsets: [work-items] --- Body @@ -5850,9 +5870,23 @@ Body let (fm, _) = super::super::common::parse_markdown(content).unwrap(); let ado = fm.tools.as_ref().unwrap().azure_devops.as_ref().unwrap(); assert!(ado.is_enabled()); - assert_eq!(ado.toolsets(), &["wit"]); + assert_eq!(ado.toolsets(), &["work-items"]); assert!(ado.allowed().is_empty()); assert!(ado.org().is_none()); + assert!(ado.version().is_none()); + } + + #[test] + fn test_azure_devops_version_requires_exact_semver() { + for version in ["latest", "next", "^2.8.0", "2.8", "v2.8.1"] { + let content = format!( + "---\nname: Test\ndescription: Test\ntools:\n azure-devops:\n version: {version}\n---\n" + ); + assert!( + super::super::common::parse_markdown(&content).is_err(), + "{version} must not be accepted as an exact MCP package version" + ); + } } // ─── LeanRuntimeConfig deserialization ────────────────────────────── @@ -5919,7 +5953,7 @@ tools: edit: true cache-memory: true azure-devops: - toolsets: [wit] + toolsets: [work-items] runtimes: lean: true --- diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index d548e417..5cf30416 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -433,6 +433,7 @@ mod tests { assert_eq!(versions.copilot_cli, COPILOT_CLI_VERSION); assert_eq!(versions.awf, AWF_VERSION); assert_eq!(versions.mcpg, MCPG_VERSION); + assert_eq!(versions.ado_mcp, ADO_MCP_VERSION); // Only the versions category is populated for --kind versions. assert!(catalog.safe_outputs.is_empty()); assert!(catalog.models.is_empty()); @@ -451,6 +452,7 @@ mod tests { assert_eq!(value["versions"]["copilot_cli"], COPILOT_CLI_VERSION); assert_eq!(value["versions"]["awf"], AWF_VERSION); assert_eq!(value["versions"]["mcpg"], MCPG_VERSION); + assert_eq!(value["versions"]["ado_mcp"], ADO_MCP_VERSION); } #[test] diff --git a/src/secure.rs b/src/secure.rs index 074c7de4..579739cb 100644 --- a/src/secure.rs +++ b/src/secure.rs @@ -37,6 +37,7 @@ //! - [`AdoProject`] — an Azure DevOps project name or GUID. //! - [`AdoRepository`] — an Azure DevOps repository name or GUID. //! - [`Version`] — a version string (`1.2.3`, `latest`). +//! - [`SemanticVersion`] — an exact semantic version (`1.2.3`, `2.0.0-beta.1`). //! //! New safe-output tools that accept paths or identifiers should type those //! fields with these newtypes instead of raw `String` so the checks are applied @@ -325,6 +326,20 @@ validated_string! { } } +validated_string! { + /// An exact semantic version (e.g. `1.2.3`, `2.0.0-beta.1`). + SemanticVersion, "semantic version", |value: &str, label: &str| { + if semver::Version::parse(value).is_ok() { + Ok(()) + } else { + anyhow::bail!( + "{label} '{value}' must be an exact semantic version such as '2.8.1'; \ + npm tags and ranges are not allowed" + ) + } + } +} + validated_string! { /// An Azure DevOps Artifacts feed reference (`feed` or `project/feed`). FeedRef, "feed", |value: &str, label: &str| { @@ -588,6 +603,18 @@ mod tests { assert!(ArtifactName::parse("a".repeat(101).as_str()).is_err()); } + #[test] + fn semantic_version_requires_an_exact_semver() { + assert!(SemanticVersion::parse("2.8.1").is_ok()); + assert!(SemanticVersion::parse("3.0.0-beta.1").is_ok()); + assert!(SemanticVersion::parse("latest").is_err()); + assert!(SemanticVersion::parse("next").is_err()); + assert!(SemanticVersion::parse("^2.8.0").is_err()); + assert!(SemanticVersion::parse("2.8").is_err()); + assert!(SemanticVersion::parse("v2.8.1").is_err()); + assert!(SemanticVersion::parse("2.8.1; echo bad").is_err()); + } + #[test] fn ado_project_name_or_guid_rules() { assert!(AdoProject::parse("Agent Playground").is_ok()); diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index fa3c72af..3784da1a 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -2057,6 +2057,30 @@ fn test_fixture_azure_devops_mcp_requires_read_permission() { ); } +#[test] +fn test_azure_devops_mcp_exact_version_override_reaches_install_step() { + let compiled = compile_inline_agent( + "ado-mcp-version-override", + r#"--- +name: "Azure DevOps MCP version override" +description: "Tests exact package version code generation" +tools: + azure-devops: + org: myorg + version: 2.9.0 +permissions: + read: my-read-arm-connection +--- + +Test. +"#, + ); + + assert!(compiled.contains("\"@azure-devops/mcp@2.9.0\"")); + assert!(compiled.contains("expected 2.9.0")); + assert!(!compiled.contains("\"@azure-devops/mcp@2.8.1\"")); +} + /// Test that the Azure DevOps MCP fixture compiles successfully with no unreplaced markers #[test] fn test_fixture_azure_devops_mcp_compiled_output() { @@ -2132,6 +2156,11 @@ fn test_fixture_azure_devops_mcp_compiled_output() { compiled.contains("node:20-slim"), "MCPG config should contain the container image" ); + assert!( + compiled.contains("\"@azure-devops/mcp@2.8.1\"") + && compiled.contains("expected 2.8.1"), + "the unversioned frontmatter form must use and verify the compiler default" + ); assert!( compiled.contains("\"entrypoint\""), "MCPG config should have entrypoint field"