|
| 1 | +// ─── Azure DevOps MCP ──────────────────────────────────────────────── |
| 2 | + |
| 3 | +use super::{ |
| 4 | + CompileContext, CompilerExtension, ExtensionPhase, McpgServerConfig, PipelineEnvMapping, |
| 5 | +}; |
| 6 | +use crate::allowed_hosts::mcp_required_hosts; |
| 7 | +use crate::compile::common::{ |
| 8 | + ADO_MCP_ENTRYPOINT, ADO_MCP_IMAGE, ADO_MCP_PACKAGE, ADO_MCP_SERVER_NAME, |
| 9 | +}; |
| 10 | +use crate::compile::types::AzureDevOpsToolConfig; |
| 11 | +use anyhow::Result; |
| 12 | +use std::collections::HashMap; |
| 13 | + |
| 14 | +/// Azure DevOps first-party tool extension. |
| 15 | +/// |
| 16 | +/// Injects: network hosts (ADO domains), MCPG server entry (containerized |
| 17 | +/// ADO MCP), and compile-time validation (org inference, duplicate MCP). |
| 18 | +pub struct AzureDevOpsExtension { |
| 19 | + config: AzureDevOpsToolConfig, |
| 20 | + auth_mode: AdoAuthMode, |
| 21 | +} |
| 22 | + |
| 23 | +/// Authentication mode for the ADO MCP server. |
| 24 | +/// |
| 25 | +/// Pipelines use bearer tokens (JWT from ARM service connections). |
| 26 | +/// Local development uses PATs (Personal Access Tokens). |
| 27 | +#[derive(Debug, Clone, Copy, Default)] |
| 28 | +pub enum AdoAuthMode { |
| 29 | + /// `-a envvar` + `ADO_MCP_AUTH_TOKEN` — bearer JWT from ARM (pipeline default) |
| 30 | + #[default] |
| 31 | + Bearer, |
| 32 | + /// `-a pat` + `AZURE_DEVOPS_EXT_PAT` — Personal Access Token (local dev) |
| 33 | + Pat, |
| 34 | +} |
| 35 | + |
| 36 | +impl AzureDevOpsExtension { |
| 37 | + pub fn new(config: AzureDevOpsToolConfig) -> Self { |
| 38 | + Self { |
| 39 | + config, |
| 40 | + auth_mode: AdoAuthMode::default(), |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + /// Set the authentication mode (e.g., `AdoAuthMode::Pat` for local runs). |
| 45 | + pub fn with_auth_mode(mut self, mode: AdoAuthMode) -> Self { |
| 46 | + self.auth_mode = mode; |
| 47 | + self |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +impl CompilerExtension for AzureDevOpsExtension { |
| 52 | + fn name(&self) -> &str { |
| 53 | + "Azure DevOps MCP" |
| 54 | + } |
| 55 | + |
| 56 | + fn phase(&self) -> ExtensionPhase { |
| 57 | + ExtensionPhase::Tool |
| 58 | + } |
| 59 | + |
| 60 | + fn required_hosts(&self) -> Vec<String> { |
| 61 | + let mut hosts: Vec<String> = mcp_required_hosts("ado") |
| 62 | + .iter() |
| 63 | + .map(|h| (*h).to_string()) |
| 64 | + .collect(); |
| 65 | + // The ADO MCP runs in a container via `npx -y @azure-devops/mcp`. |
| 66 | + // npx needs npm registry access to resolve and install the package. |
| 67 | + hosts.push("node".to_string()); |
| 68 | + hosts |
| 69 | + } |
| 70 | + |
| 71 | + fn allowed_copilot_tools(&self) -> Vec<String> { |
| 72 | + vec![ADO_MCP_SERVER_NAME.to_string()] |
| 73 | + } |
| 74 | + |
| 75 | + fn mcpg_servers(&self, ctx: &CompileContext) -> Result<Vec<(String, McpgServerConfig)>> { |
| 76 | + // Build entrypoint args: npx -y @azure-devops/mcp <org> [-d toolset1 toolset2 ...] |
| 77 | + let mut entrypoint_args = vec!["-y".to_string(), ADO_MCP_PACKAGE.to_string()]; |
| 78 | + |
| 79 | + // Org: use explicit override, then inferred from git remote, then fail |
| 80 | + let org = self |
| 81 | + .config |
| 82 | + .org() |
| 83 | + .map(|s| s.to_string()) |
| 84 | + .or_else(|| ctx.ado_org().map(|s| s.to_string())) |
| 85 | + .ok_or_else(|| { |
| 86 | + anyhow::anyhow!( |
| 87 | + "Agent '{}' has tools.azure-devops enabled but no ADO organization could be \ |
| 88 | + determined. Either set tools.azure-devops.org explicitly, or compile from \ |
| 89 | + within a git repository with an Azure DevOps remote URL.", |
| 90 | + ctx.agent_name |
| 91 | + ) |
| 92 | + })?; |
| 93 | + if !org.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { |
| 94 | + anyhow::bail!( |
| 95 | + "Invalid ADO org name '{}': must contain only alphanumerics and hyphens", |
| 96 | + org |
| 97 | + ); |
| 98 | + } |
| 99 | + entrypoint_args.push(org); |
| 100 | + |
| 101 | + // Toolsets: passed as -d flag followed by space-separated toolset names |
| 102 | + if !self.config.toolsets().is_empty() { |
| 103 | + entrypoint_args.push("-d".to_string()); |
| 104 | + for toolset in self.config.toolsets() { |
| 105 | + if !toolset |
| 106 | + .chars() |
| 107 | + .all(|c| c.is_ascii_alphanumeric() || c == '-') |
| 108 | + { |
| 109 | + anyhow::bail!( |
| 110 | + "Invalid ADO toolset name '{}': must contain only alphanumerics and hyphens", |
| 111 | + toolset |
| 112 | + ); |
| 113 | + } |
| 114 | + entrypoint_args.push(toolset.clone()); |
| 115 | + } |
| 116 | + } |
| 117 | + |
| 118 | + // Tool allow-list for MCPG filtering |
| 119 | + let tools = if self.config.allowed().is_empty() { |
| 120 | + None |
| 121 | + } else { |
| 122 | + Some(self.config.allowed().to_vec()) |
| 123 | + }; |
| 124 | + |
| 125 | + // ADO MCP authentication: the @azure-devops/mcp npm package accepts |
| 126 | + // auth type via CLI arg (-a) and token via env var. |
| 127 | + // Bearer: `-a envvar` reads ADO_MCP_AUTH_TOKEN (pipeline JWT from ARM) |
| 128 | + // Pat: `-a pat` reads PERSONAL_ACCESS_TOKEN (base64-encoded PAT) |
| 129 | + let (auth_flag, token_var) = match self.auth_mode { |
| 130 | + AdoAuthMode::Bearer => ("envvar", "ADO_MCP_AUTH_TOKEN"), |
| 131 | + AdoAuthMode::Pat => ("pat", "PERSONAL_ACCESS_TOKEN"), |
| 132 | + }; |
| 133 | + entrypoint_args.extend(["-a".to_string(), auth_flag.to_string()]); |
| 134 | + |
| 135 | + let env = Some(HashMap::from([( |
| 136 | + token_var.to_string(), |
| 137 | + String::new(), // Passthrough from MCPG process env |
| 138 | + )])); |
| 139 | + |
| 140 | + // --network host: AWF's DOCKER-USER iptables rules block outbound from |
| 141 | + // containers on Docker's default bridge. Host networking bypasses FORWARD |
| 142 | + // chain rules so the ADO MCP can reach dev.azure.com. |
| 143 | + // This matches gh-aw's approach for its built-in agentic-workflows MCP. |
| 144 | + let args = Some(vec!["--network".to_string(), "host".to_string()]); |
| 145 | + |
| 146 | + Ok(vec![( |
| 147 | + ADO_MCP_SERVER_NAME.to_string(), |
| 148 | + McpgServerConfig { |
| 149 | + server_type: "stdio".to_string(), |
| 150 | + container: Some(ADO_MCP_IMAGE.to_string()), |
| 151 | + entrypoint: Some(ADO_MCP_ENTRYPOINT.to_string()), |
| 152 | + entrypoint_args: Some(entrypoint_args), |
| 153 | + mounts: None, |
| 154 | + args, |
| 155 | + url: None, |
| 156 | + headers: None, |
| 157 | + env, |
| 158 | + tools, |
| 159 | + }, |
| 160 | + )]) |
| 161 | + } |
| 162 | + |
| 163 | + fn validate(&self, ctx: &CompileContext) -> Result<Vec<String>> { |
| 164 | + let mut warnings = Vec::new(); |
| 165 | + |
| 166 | + // Warn if user also has a manual mcp-servers entry for azure-devops |
| 167 | + if ctx |
| 168 | + .front_matter |
| 169 | + .mcp_servers |
| 170 | + .contains_key(ADO_MCP_SERVER_NAME) |
| 171 | + { |
| 172 | + warnings.push(format!( |
| 173 | + "Agent '{}' has both tools.azure-devops and mcp-servers.azure-devops configured. \ |
| 174 | + The tools.azure-devops auto-configuration takes precedence. \ |
| 175 | + Remove the mcp-servers entry to silence this warning.", |
| 176 | + ctx.agent_name |
| 177 | + )); |
| 178 | + } |
| 179 | + |
| 180 | + Ok(warnings) |
| 181 | + } |
| 182 | + fn required_pipeline_vars(&self) -> Vec<PipelineEnvMapping> { |
| 183 | + match self.auth_mode { |
| 184 | + AdoAuthMode::Bearer => vec![PipelineEnvMapping { |
| 185 | + container_var: "ADO_MCP_AUTH_TOKEN".to_string(), |
| 186 | + pipeline_var: "SC_READ_TOKEN".to_string(), |
| 187 | + }], |
| 188 | + // PAT mode: no pipeline var mapping needed — the PAT is passed |
| 189 | + // directly via AZURE_DEVOPS_EXT_PAT in the MCPG env file. |
| 190 | + AdoAuthMode::Pat => vec![], |
| 191 | + } |
| 192 | + } |
| 193 | +} |
0 commit comments