@@ -1137,6 +1137,141 @@ pub fn generate_agentic_depends_on(setup_steps: &[serde_yaml::Value]) -> String
11371137 }
11381138}
11391139
1140+ /// Returns `Some(v.to_vec())` when `v` is non-empty, otherwise `None`.
1141+ fn nonempty_vec < T : Clone > ( v : & [ T ] ) -> Option < Vec < T > > {
1142+ if v. is_empty ( ) { None } else { Some ( v. to_vec ( ) ) }
1143+ }
1144+
1145+ /// Returns `Some(m.clone())` when `m` is non-empty, otherwise `None`.
1146+ fn nonempty_map < K , V > ( m : & HashMap < K , V > ) -> Option < HashMap < K , V > >
1147+ where
1148+ K : Clone + Eq + std:: hash:: Hash ,
1149+ V : Clone ,
1150+ {
1151+ if m. is_empty ( ) { None } else { Some ( m. clone ( ) ) }
1152+ }
1153+
1154+ /// Validate a container-based MCP entry and emit any warnings.
1155+ fn validate_stdio_mcp ( name : & str , container : & str , opts : & crate :: compile:: types:: McpOptions ) {
1156+ for w in validate:: validate_container_image ( container, name) { eprintln ! ( "{}" , w) ; }
1157+ for mount in & opts. mounts {
1158+ for w in validate:: validate_mount_source ( mount, name) { eprintln ! ( "{}" , w) ; }
1159+ }
1160+ for w in validate:: validate_docker_args ( & opts. args , name) { eprintln ! ( "{}" , w) ; }
1161+ for w in validate:: warn_potential_secrets ( name, & opts. env , & opts. headers ) { eprintln ! ( "{}" , w) ; }
1162+ }
1163+
1164+ /// Build a stdio `McpgServerConfig` from a container-based MCP options block.
1165+ fn build_stdio_mcpg_server ( container : & str , opts : & crate :: compile:: types:: McpOptions ) -> McpgServerConfig {
1166+ McpgServerConfig {
1167+ server_type : "stdio" . to_string ( ) ,
1168+ container : Some ( container. to_string ( ) ) ,
1169+ entrypoint : opts. entrypoint . clone ( ) ,
1170+ entrypoint_args : nonempty_vec ( & opts. entrypoint_args ) ,
1171+ mounts : nonempty_vec ( & opts. mounts ) ,
1172+ args : nonempty_vec ( & opts. args ) ,
1173+ url : None ,
1174+ headers : None ,
1175+ env : nonempty_map ( & opts. env ) ,
1176+ tools : nonempty_vec ( & opts. allowed ) ,
1177+ }
1178+ }
1179+
1180+ /// Build an HTTP `McpgServerConfig` from a URL-based MCP options block.
1181+ fn build_http_mcpg_server ( url : & str , opts : & crate :: compile:: types:: McpOptions ) -> McpgServerConfig {
1182+ McpgServerConfig {
1183+ server_type : "http" . to_string ( ) ,
1184+ container : None ,
1185+ entrypoint : None ,
1186+ entrypoint_args : None ,
1187+ mounts : None ,
1188+ args : None ,
1189+ url : Some ( url. to_string ( ) ) ,
1190+ headers : nonempty_map ( & opts. headers ) ,
1191+ env : None ,
1192+ tools : nonempty_vec ( & opts. allowed ) ,
1193+ }
1194+ }
1195+
1196+ /// Validate and insert a single user-defined MCP server into `servers`.
1197+ ///
1198+ /// Returns `Ok(())` on success. Returns `Err` for invalid server names.
1199+ /// Silently skips reserved names, disabled entries, and unconfigured entries.
1200+ fn try_add_user_mcp (
1201+ name : & str ,
1202+ config : & McpConfig ,
1203+ servers : & mut HashMap < String , McpgServerConfig > ,
1204+ ) -> Result < ( ) > {
1205+ // Prevent user-defined MCPs from overwriting the reserved safeoutputs backend
1206+ if name. eq_ignore_ascii_case ( "safeoutputs" ) {
1207+ log:: warn!(
1208+ "MCP name 'safeoutputs' is reserved for the safe outputs HTTP backend — skipping"
1209+ ) ;
1210+ return Ok ( ( ) ) ;
1211+ }
1212+
1213+ // Validate server name for URL safety — names are embedded in MCPG routed
1214+ // endpoints (/mcp/{name}) and must be safe URL path segments.
1215+ // Leading dots are rejected to prevent path normalization issues (e.g., ".." → parent).
1216+ if name. is_empty ( )
1217+ || name. starts_with ( '.' )
1218+ || !name. chars ( ) . all ( |c| c. is_ascii_alphanumeric ( ) || c == '-' || c == '_' || c == '.' )
1219+ {
1220+ anyhow:: bail!(
1221+ "MCP server name '{}' is invalid — must be non-empty, not start with '.', and contain only ASCII alphanumerics, hyphens, underscores, and dots" ,
1222+ name
1223+ ) ;
1224+ }
1225+
1226+ // Skip if already auto-configured by an extension (e.g., tools.azure-devops)
1227+ if servers. contains_key ( name) {
1228+ return Ok ( ( ) ) ;
1229+ }
1230+
1231+ let opts = match config {
1232+ McpConfig :: Enabled ( false ) => return Ok ( ( ) ) ,
1233+ McpConfig :: Enabled ( true ) => {
1234+ log:: warn!( "MCP '{}' has no container or url — skipping" , name) ;
1235+ return Ok ( ( ) ) ;
1236+ }
1237+ McpConfig :: WithOptions ( opts) => {
1238+ if !opts. enabled . unwrap_or ( true ) {
1239+ return Ok ( ( ) ) ;
1240+ }
1241+ opts
1242+ }
1243+ } ;
1244+
1245+ if opts. container . is_some ( ) && opts. url . is_some ( ) {
1246+ log:: warn!(
1247+ "MCP '{}': both 'container' and 'url' are set — using 'container' (stdio). \
1248+ Remove 'url' to silence this warning.",
1249+ name
1250+ ) ;
1251+ }
1252+
1253+ if let Some ( container) = & opts. container {
1254+ validate_stdio_mcp ( name, container, opts) ;
1255+ servers. insert ( name. to_string ( ) , build_stdio_mcpg_server ( container, opts) ) ;
1256+ } else if let Some ( url) = & opts. url {
1257+ // HTTP-based MCP (remote server)
1258+ for w in validate:: validate_mcp_url ( url, name) { eprintln ! ( "{}" , w) ; }
1259+ for w in validate:: warn_potential_secrets ( name, & HashMap :: new ( ) , & opts. headers ) { eprintln ! ( "{}" , w) ; }
1260+ if !opts. env . is_empty ( ) {
1261+ eprintln ! (
1262+ "Warning: MCP '{}': env vars are not supported for HTTP MCPs — they will be ignored. \
1263+ Use headers for authentication instead.",
1264+ name
1265+ ) ;
1266+ }
1267+ servers. insert ( name. to_string ( ) , build_http_mcpg_server ( url, opts) ) ;
1268+ } else {
1269+ log:: warn!( "MCP '{}' has no container or url — skipping" , name) ;
1270+ }
1271+
1272+ Ok ( ( ) )
1273+ }
1274+
11401275/// Generate MCPG configuration from front matter.
11411276///
11421277/// Converts the front matter `mcp-servers` definitions into MCPG-compatible JSON.
@@ -1157,145 +1292,7 @@ pub fn generate_mcpg_config(
11571292 }
11581293
11591294 for ( name, config) in & front_matter. mcp_servers {
1160- // Prevent user-defined MCPs from overwriting the reserved safeoutputs backend
1161- if name. eq_ignore_ascii_case ( "safeoutputs" ) {
1162- log:: warn!(
1163- "MCP name 'safeoutputs' is reserved for the safe outputs HTTP backend — skipping"
1164- ) ;
1165- continue ;
1166- }
1167-
1168- // Validate server name for URL safety — names are embedded in MCPG routed
1169- // endpoints (/mcp/{name}) and must be safe URL path segments.
1170- // Leading dots are rejected to prevent path normalization issues (e.g., ".." → parent).
1171- if name. is_empty ( )
1172- || name. starts_with ( '.' )
1173- || !name. chars ( ) . all ( |c| c. is_ascii_alphanumeric ( ) || c == '-' || c == '_' || c == '.' )
1174- {
1175- anyhow:: bail!(
1176- "MCP server name '{}' is invalid — must be non-empty, not start with '.', and contain only ASCII alphanumerics, hyphens, underscores, and dots" ,
1177- name
1178- ) ;
1179- }
1180-
1181- // Skip if already auto-configured by an extension (e.g., tools.azure-devops)
1182- if mcp_servers. contains_key ( name) {
1183- continue ;
1184- }
1185-
1186- let ( is_enabled, options) = match config {
1187- McpConfig :: Enabled ( enabled) => ( * enabled, None ) ,
1188- McpConfig :: WithOptions ( opts) => ( opts. enabled . unwrap_or ( true ) , Some ( opts) ) ,
1189- } ;
1190-
1191- if !is_enabled {
1192- continue ;
1193- }
1194-
1195- if let Some ( opts) = options {
1196- if opts. container . is_some ( ) && opts. url . is_some ( ) {
1197- log:: warn!(
1198- "MCP '{}': both 'container' and 'url' are set — using 'container' (stdio). \
1199- Remove 'url' to silence this warning.",
1200- name
1201- ) ;
1202- }
1203-
1204- if let Some ( container) = & opts. container {
1205- // Container-based stdio MCP (MCPG-native, per spec §3.2.1)
1206- for w in validate:: validate_container_image ( container, name) { eprintln ! ( "{}" , w) ; }
1207- // Validate mount paths for sensitive host directories
1208- for mount in & opts. mounts {
1209- for w in validate:: validate_mount_source ( mount, name) { eprintln ! ( "{}" , w) ; }
1210- }
1211- // Validate Docker runtime args for privilege escalation
1212- for w in validate:: validate_docker_args ( & opts. args , name) { eprintln ! ( "{}" , w) ; }
1213- // Warn about potential inline secrets (check headers too in case user set both)
1214- for w in validate:: warn_potential_secrets ( name, & opts. env , & opts. headers ) { eprintln ! ( "{}" , w) ; }
1215- let entrypoint_args = if opts. entrypoint_args . is_empty ( ) {
1216- None
1217- } else {
1218- Some ( opts. entrypoint_args . clone ( ) )
1219- } ;
1220- let args = if opts. args . is_empty ( ) {
1221- None
1222- } else {
1223- Some ( opts. args . clone ( ) )
1224- } ;
1225- let mounts = if opts. mounts . is_empty ( ) {
1226- None
1227- } else {
1228- Some ( opts. mounts . clone ( ) )
1229- } ;
1230- let env = if opts. env . is_empty ( ) {
1231- None
1232- } else {
1233- Some ( opts. env . clone ( ) )
1234- } ;
1235- let tools = if opts. allowed . is_empty ( ) {
1236- None
1237- } else {
1238- Some ( opts. allowed . clone ( ) )
1239- } ;
1240- mcp_servers. insert (
1241- name. clone ( ) ,
1242- McpgServerConfig {
1243- server_type : "stdio" . to_string ( ) ,
1244- container : Some ( container. clone ( ) ) ,
1245- entrypoint : opts. entrypoint . clone ( ) ,
1246- entrypoint_args,
1247- mounts,
1248- args,
1249- url : None ,
1250- headers : None ,
1251- env,
1252- tools,
1253- } ,
1254- ) ;
1255- } else if let Some ( url) = & opts. url {
1256- // HTTP-based MCP (remote server)
1257- for w in validate:: validate_mcp_url ( url, name) { eprintln ! ( "{}" , w) ; }
1258- // Warn about potential inline secrets in headers
1259- for w in validate:: warn_potential_secrets ( name, & HashMap :: new ( ) , & opts. headers ) { eprintln ! ( "{}" , w) ; }
1260- if !opts. env . is_empty ( ) {
1261- eprintln ! (
1262- "Warning: MCP '{}': env vars are not supported for HTTP MCPs — they will be ignored. \
1263- Use headers for authentication instead.",
1264- name
1265- ) ;
1266- }
1267- let headers = if opts. headers . is_empty ( ) {
1268- None
1269- } else {
1270- Some ( opts. headers . clone ( ) )
1271- } ;
1272- let tools = if opts. allowed . is_empty ( ) {
1273- None
1274- } else {
1275- Some ( opts. allowed . clone ( ) )
1276- } ;
1277- mcp_servers. insert (
1278- name. clone ( ) ,
1279- McpgServerConfig {
1280- server_type : "http" . to_string ( ) ,
1281- container : None ,
1282- entrypoint : None ,
1283- entrypoint_args : None ,
1284- mounts : None ,
1285- args : None ,
1286- url : Some ( url. clone ( ) ) ,
1287- headers,
1288- env : None ,
1289- tools,
1290- } ,
1291- ) ;
1292- } else {
1293- log:: warn!( "MCP '{}' has no container or url — skipping" , name) ;
1294- continue ;
1295- }
1296- } else {
1297- log:: warn!( "MCP '{}' has no container or url — skipping" , name) ;
1298- }
1295+ try_add_user_mcp ( name, config, & mut mcp_servers) ?;
12991296 }
13001297
13011298 Ok ( McpgConfig {
0 commit comments