diff --git a/.changeset/feat_env_var_expansion.md b/.changeset/feat_env_var_expansion.md new file mode 100644 index 000000000..bc00fddf8 --- /dev/null +++ b/.changeset/feat_env_var_expansion.md @@ -0,0 +1,15 @@ +### feat: add support for custom environment variable expansion - @gocamille PR #539 + +#### Summary + +This PR adds support for `${env.VAR_NAME}` syntax in configuration files, allowing users to reference custom environment variables without being limited to the `APOLLO_MCP_*` naming convention. + +Closes #454. + +#### Changes + +- `runtime/env_expansion.rs` (new module) - parser for variable expansion +- `runtime.rs` (modified) - integrates expansion into the `read_config()` function +- `config-file.mdx` - updated docs with syntax, escaping, and special characters handling + +- **Note** The `APOLLO_MCP_*` environment variable(s) will still take precedence over expanded custom config values (no breaking change). \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 138069c83..236b9ea89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shellexpand", "syn 2.0.106", "tempfile", "thiserror 2.0.17", @@ -2994,7 +2995,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror 2.0.17", "tokio", "tracing", @@ -3031,9 +3032,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3755,6 +3756,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" + [[package]] name = "shlex" version = "1.3.0" diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index a04efb5e7..b210954ab 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -56,6 +56,8 @@ rmcp = { version = "0.9", features = [ schemars = { version = "1.0.1", features = ["url2"] } serde.workspace = true serde_json.workspace = true +serde_yaml = "0.9.34" +shellexpand = { version = "3.1", default-features = false, features = ["base-0"] } thiserror.workspace = true tokio.workspace = true tokio-util = "0.7.15" diff --git a/crates/apollo-mcp-server/src/env_expansion.rs b/crates/apollo-mcp-server/src/env_expansion.rs new file mode 100644 index 000000000..a146e8393 --- /dev/null +++ b/crates/apollo-mcp-server/src/env_expansion.rs @@ -0,0 +1,619 @@ +//! Environment variable expansion for configuration files. +//! +//! Supports `${env.VAR_NAME}` and `${env.VAR_NAME:-default}` syntax. + +use serde_yaml::Value; + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum EnvExpansionError { + #[error("undefined environment variable '{name}' referenced in configuration")] + UndefinedVariable { name: String }, + + #[error("environment variable '{name}' contains non-UTF8 data")] + NonUnicodeValue { name: String }, + + #[error("failed to parse YAML: {0}")] + YamlParse(String), + + #[error("failed to serialize YAML: {0}")] + YamlSerialize(String), +} + +/// Expand environment variables in YAML content with type coercion. +/// +/// This is the main entry point. It parses the YAML, walks the AST to expand +/// environment variables, coerces types, and returns the expanded YAML string. +pub fn expand_yaml(yaml_content: &str) -> Result { + // Parse YAML to AST + let mut value: Value = serde_yaml::from_str(yaml_content) + .map_err(|e| EnvExpansionError::YamlParse(e.to_string()))?; + + // Walk and expand environment variables + expand_value(&mut value)?; + + // Serialize back to YAML string + serde_yaml::to_string(&value).map_err(|e| EnvExpansionError::YamlSerialize(e.to_string())) +} + +/// Recursively walk the YAML AST and expand environment variables in string nodes. +fn expand_value(value: &mut Value) -> Result<(), EnvExpansionError> { + match value { + Value::String(s) => { + // Only expand if the string contains potential placeholders + if s.contains("${") || s.contains("$$") { + let expanded = expand_env_vars(s)?; + // Coerce the expanded string to the appropriate type + *value = coerce(&expanded); + } + } + Value::Sequence(seq) => { + for item in seq { + expand_value(item)?; + } + } + Value::Mapping(map) => { + for (_, v) in map { + expand_value(v)?; + } + } + _ => {} + } + Ok(()) +} + +/// Coerce an expanded string to its natural YAML type. +/// +/// # Examples +/// +/// - `"true"` → `Bool(true)` +/// - `"false"` → `Bool(false)` +/// - `"8080"` → `Number(8080)` +/// - `"3.14"` → `Number(3.14)` +/// - `"null"` → `Null` +/// - `"hello"` → `String("hello")` +/// - `"port: 8080"` → `String("port: 8080")` (stays string, not parsed as mapping) +pub fn coerce(s: &str) -> Value { + match serde_yaml::from_str(s) { + Ok(Value::Bool(b)) => Value::Bool(b), + Ok(Value::Number(n)) => Value::Number(n), + Ok(Value::Null) => Value::Null, + // Everything else (including Mapping, Sequence, Tagged) stays as String + // This prevents "key: value" from being parsed as a nested structure + _ => Value::String(s.to_string()), + } +} + +/// Expand all `${env.VAR_NAME}` references in the string. +pub(super) fn expand_env_vars(content: &str) -> Result { + shellexpand::env_with_context(content, context_fn) + .map(|cow| cow.into_owned()) + .map_err(|e| e.cause) +} + +fn context_fn(key: &str) -> Result, EnvExpansionError> { + let Some(var_name) = key.strip_prefix("env.") else { + return Ok(None); + }; + + match std::env::var(var_name) { + Ok(value) if !value.is_empty() => Ok(Some(value)), + Ok(_) | Err(std::env::VarError::NotPresent) => Err(EnvExpansionError::UndefinedVariable { + name: var_name.to_string(), + }), + Err(std::env::VarError::NotUnicode(_)) => Err(EnvExpansionError::NonUnicodeValue { + name: var_name.to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_yaml_coerces_boolean() { + figment::Jail::expect_with(|jail| { + jail.set_env("MY_BOOL", "true"); + let yaml = "enabled: \"${env.MY_BOOL}\""; + let result = expand_yaml(yaml).unwrap(); + // Should be coerced to boolean, not string "true" + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!(parsed["enabled"], Value::Bool(true)); + Ok(()) + }); + } + + #[test] + fn expand_yaml_coerces_number() { + figment::Jail::expect_with(|jail| { + jail.set_env("MY_PORT", "8080"); + let yaml = "port: \"${env.MY_PORT}\""; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert!(parsed["port"].is_number()); + assert_eq!(parsed["port"].as_u64(), Some(8080)); + Ok(()) + }); + } + + #[test] + fn expand_yaml_coerces_null() { + figment::Jail::expect_with(|jail| { + jail.set_env("MY_NULL", "null"); + let yaml = "value: \"${env.MY_NULL}\""; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert!(parsed["value"].is_null()); + Ok(()) + }); + } + + #[test] + fn expand_yaml_preserves_string() { + figment::Jail::expect_with(|jail| { + jail.set_env("MY_STRING", "hello world"); + let yaml = "name: \"${env.MY_STRING}\""; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!(parsed["name"].as_str(), Some("hello world")); + Ok(()) + }); + } + + #[test] + fn expand_yaml_handles_special_chars_safely() { + // This would break with pre-parse substitution! + figment::Jail::expect_with(|jail| { + jail.set_env("MY_VALUE", "key: value with colon"); + let yaml = "description: \"${env.MY_VALUE}\""; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + // Should be a string, not a nested mapping + assert_eq!( + parsed["description"].as_str(), + Some("key: value with colon") + ); + Ok(()) + }); + } + + #[test] + fn expand_yaml_expands_nested_structures() { + figment::Jail::expect_with(|jail| { + jail.set_env("PORT", "3000"); + jail.set_env("ENABLED", "true"); + let yaml = r#" +server: + port: "${env.PORT}" + nested: + enabled: "${env.ENABLED}" +"#; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!(parsed["server"]["port"].as_u64(), Some(3000)); + assert_eq!(parsed["server"]["nested"]["enabled"], Value::Bool(true)); + Ok(()) + }); + } + + #[test] + fn expand_yaml_expands_arrays() { + figment::Jail::expect_with(|jail| { + jail.set_env("VAL1", "first"); + jail.set_env("VAL2", "42"); + let yaml = r#" +items: + - "${env.VAL1}" + - "${env.VAL2}" +"#; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!(parsed["items"][0].as_str(), Some("first")); + assert_eq!(parsed["items"][1].as_u64(), Some(42)); + Ok(()) + }); + } + + #[test] + fn expand_yaml_unquoted_also_coerces() { + // Unquoted env vars should also work (parsed as strings by YAML first) + figment::Jail::expect_with(|jail| { + jail.set_env("MY_NUM", "123"); + // Note: unquoted ${env.X} is parsed as a string by serde_yaml + let yaml = "count: ${env.MY_NUM}"; + let result = expand_yaml(yaml).unwrap(); + let parsed: Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!(parsed["count"].as_u64(), Some(123)); + Ok(()) + }); + } + + // ========================================================================= + // Tests for coerce function + // ========================================================================= + + #[test] + fn coerce_true_to_bool() { + assert_eq!(coerce("true"), Value::Bool(true)); + } + + #[test] + fn coerce_false_to_bool() { + assert_eq!(coerce("false"), Value::Bool(false)); + } + + #[test] + fn coerce_integer_to_number() { + let result = coerce("42"); + assert!(result.is_number()); + assert_eq!(result.as_u64(), Some(42)); + } + + #[test] + fn coerce_float_to_number() { + let result = coerce("2.5"); + assert!(result.is_number()); + assert!((result.as_f64().unwrap() - 2.5).abs() < 0.001); + } + + #[test] + fn coerce_null_to_null() { + assert!(coerce("null").is_null()); + } + + #[test] + fn coerce_mapping_stays_string() { + // "key: value" would parse as a mapping, but we want it as a string + let result = coerce("key: value"); + assert_eq!(result, Value::String("key: value".to_string())); + } + + #[test] + fn coerce_sequence_stays_string() { + let result = coerce("[1, 2, 3]"); + assert_eq!(result, Value::String("[1, 2, 3]".to_string())); + } + + // ========================================================================= + // Tests for expand_env_vars (string-level expansion) + // ========================================================================= + + #[test] + fn expands_single_env_var() { + figment::Jail::expect_with(|jail| { + jail.set_env("TEST_VAR", "expanded_value"); + let result = expand_env_vars("endpoint: ${env.TEST_VAR}").unwrap(); + assert_eq!(result, "endpoint: expanded_value"); + Ok(()) + }); + } + + #[test] + fn expands_multiple_env_vars() { + figment::Jail::expect_with(|jail| { + jail.set_env("VAR_ONE", "first"); + jail.set_env("VAR_TWO", "second"); + let result = expand_env_vars("a: ${env.VAR_ONE}, b: ${env.VAR_TWO}").unwrap(); + assert_eq!(result, "a: first, b: second"); + Ok(()) + }); + } + + #[test] + fn preserves_content_without_env_vars() { + let input = "endpoint: http://localhost:4000\nport: 8080"; + let result = expand_env_vars(input).unwrap(); + assert_eq!(result, input); + } + + #[test] + fn errors_on_undefined_env_var() { + let result = expand_env_vars("val: ${env._NONEXISTENT_VAR_12345_}"); + assert_eq!( + result.unwrap_err(), + EnvExpansionError::UndefinedVariable { + name: "_NONEXISTENT_VAR_12345_".into() + } + ); + } + + #[test] + fn leaves_non_env_prefixed_vars_literal() { + // Variables without "env." prefix are left literal (context returns Ok(None)) + let literal_cases = [ + ("${VAR}", "${VAR}"), + ("$env.VAR", "$env.VAR"), + ("${other.VAR}", "${other.VAR}"), + ]; + for (input, expected) in literal_cases { + let result = expand_env_vars(input).unwrap(); + assert_eq!(result, expected, "should stay literal: {}", input); + } + } + + #[test] + fn errors_on_empty_var_name() { + let result = expand_env_vars("${env.}"); + assert!(result.is_err()); + } + + #[test] + fn allows_numeric_start_var_names() { + let result = expand_env_vars("${env.123start}"); + assert!(result.is_err()); + } + + #[test] + fn empty_var_without_default_errors() { + figment::Jail::expect_with(|jail| { + jail.set_env("EMPTY_VAR", ""); + let result = expand_env_vars("val: ${env.EMPTY_VAR}"); + assert_eq!( + result.unwrap_err(), + EnvExpansionError::UndefinedVariable { + name: "EMPTY_VAR".into() + } + ); + Ok(()) + }); + } + + #[test] + fn expands_underscore_prefixed_vars() { + figment::Jail::expect_with(|jail| { + jail.set_env("_PRIVATE_VAR", "private"); + let result = expand_env_vars("val: ${env._PRIVATE_VAR}").unwrap(); + assert_eq!(result, "val: private"); + Ok(()) + }); + } + + #[test] + fn escapes_double_dollar() { + let result = expand_env_vars("val: $${env.SOMETHING}").unwrap(); + assert_eq!(result, "val: ${env.SOMETHING}"); + } + + #[test] + fn handles_unclosed_brace() { + let result = expand_env_vars("val: ${env.VAR").unwrap(); + assert_eq!(result, "val: ${env.VAR"); + } + + #[test] + fn handles_dollar_at_end() { + let result = expand_env_vars("val: test$").unwrap(); + assert_eq!(result, "val: test$"); + } + + #[test] + fn handles_multiple_escapes() { + let result = expand_env_vars("$${A} and $${B}").unwrap(); + assert_eq!(result, "${A} and ${B}"); + } + + #[test] + fn handles_yaml_special_chars_in_value() { + figment::Jail::expect_with(|jail| { + jail.set_env("SPECIAL_VAR", "value: with colon"); + let result = expand_env_vars("endpoint: ${env.SPECIAL_VAR}").unwrap(); + assert_eq!(result, "endpoint: value: with colon"); + Ok(()) + }); + } + + #[test] + fn handles_quoted_value_with_special_chars() { + figment::Jail::expect_with(|jail| { + jail.set_env("SPECIAL_VAR", "value: with colon"); + let result = expand_env_vars("endpoint: \"${env.SPECIAL_VAR}\"").unwrap(); + assert_eq!(result, "endpoint: \"value: with colon\""); + Ok(()) + }); + } + + #[test] + fn does_not_recursively_expand() { + figment::Jail::expect_with(|jail| { + jail.set_env("OUTER", "${env.INNER}"); + jail.set_env("INNER", "should_not_appear"); + let result = expand_env_vars("val: ${env.OUTER}").unwrap(); + assert_eq!(result, "val: ${env.INNER}"); + Ok(()) + }); + } + + #[test] + fn expands_adjacent_vars() { + figment::Jail::expect_with(|jail| { + jail.set_env("A", "first"); + jail.set_env("B", "second"); + let result = expand_env_vars("${env.A}${env.B}").unwrap(); + assert_eq!(result, "firstsecond"); + Ok(()) + }); + } + + #[test] + fn escapes_first_but_expands_second() { + figment::Jail::expect_with(|jail| { + jail.set_env("B", "expanded"); + let result = expand_env_vars("$${env.A}${env.B}").unwrap(); + assert_eq!(result, "${env.A}expanded"); + Ok(()) + }); + } + + #[test] + fn uses_default_when_var_undefined() { + let result = expand_env_vars("val: ${env.UNDEFINED_VAR_XYZ:-fallback}").unwrap(); + assert_eq!(result, "val: fallback"); + } + + #[test] + fn ignores_default_when_var_defined() { + figment::Jail::expect_with(|jail| { + jail.set_env("DEFINED_VAR", "actual_value"); + let result = expand_env_vars("val: ${env.DEFINED_VAR:-fallback}").unwrap(); + assert_eq!(result, "val: actual_value"); + Ok(()) + }); + } + + #[test] + fn uses_empty_default() { + let result = expand_env_vars("val: ${env.UNDEFINED_VAR_XYZ:-}").unwrap(); + assert_eq!(result, "val: "); + } + + #[test] + fn default_with_special_chars() { + let result = + expand_env_vars("url: ${env.UNDEFINED_VAR_XYZ:-http://localhost:4000}").unwrap(); + assert_eq!(result, "url: http://localhost:4000"); + } + + #[test] + fn default_preserves_colons_in_value() { + let result = expand_env_vars("val: ${env.UNDEFINED_VAR_XYZ:-a:b:c}").unwrap(); + assert_eq!(result, "val: a:b:c"); + } + + #[test] + fn multiple_vars_with_defaults() { + figment::Jail::expect_with(|jail| { + jail.set_env("EXISTS", "real"); + let result = + expand_env_vars("${env.EXISTS:-x} ${env.MISSING_VAR_XYZ:-default}").unwrap(); + assert_eq!(result, "real default"); + Ok(()) + }); + } + + #[test] + fn default_with_nested_braces() { + let result = expand_env_vars(r#"val: ${env.UNDEFINED_VAR_XYZ:-{"key":"value"}}"#).unwrap(); + assert_eq!(result, r#"val: {"key":"value"}"#); + } + + #[test] + fn default_with_deeply_nested_braces() { + let result = expand_env_vars("val: ${env.UNDEFINED_VAR_XYZ:-{a:{b:{c:1}}}}").unwrap(); + assert_eq!(result, "val: {a:{b:{c:1}}}"); + } + + #[test] + fn empty_var_uses_default() { + figment::Jail::expect_with(|jail| { + jail.set_env("EMPTY_VAR", ""); + let result = expand_env_vars("val: ${env.EMPTY_VAR:-fallback}").unwrap(); + assert_eq!(result, "val: fallback"); + Ok(()) + }); + } + + #[test] + fn numeric_var_name_with_default_uses_default() { + let result = expand_env_vars("val: ${env.123VAR:-default}").unwrap(); + assert_eq!(result, "val: default"); + } + + #[test] + fn empty_var_name_with_default_uses_default() { + let result = expand_env_vars("val: ${env.:-default}").unwrap(); + assert_eq!(result, "val: default"); + } + + #[test] + fn default_after_nested_braces_continues_parsing() { + let result = expand_env_vars("${env.UNDEFINED_VAR_XYZ:-{}} more text").unwrap(); + assert_eq!(result, "{} more text"); + } + + #[test] + fn unbalanced_braces_in_default_outputs_literal() { + let result = expand_env_vars("val: ${env.VAR:-{unclosed").unwrap(); + assert_eq!(result, "val: ${env.VAR:-{unclosed"); + } + + #[test] + fn escape_in_default_not_processed() { + let result = + expand_env_vars("val: ${env.UNDEFINED_VAR_XYZ:-has $${env.X} inside}").unwrap(); + assert_eq!(result, "val: has $${env.X inside}"); + } + + #[test] + fn triple_dollar_sign() { + // $$ -> $, then remaining $ stays literal + // So $$$ -> $ + $ = $$ + let result = expand_env_vars("val: $$$").unwrap(); + assert_eq!(result, "val: $$"); + } + + #[test] + fn quadruple_dollar_before_placeholder() { + figment::Jail::expect_with(|jail| { + jail.set_env("X", "value"); + // $$$$ = $$ + $$ = $ + $ = $$, then {env.X} is literal (no $ before it) + let result = expand_env_vars("val: $$$${env.X}").unwrap(); + assert_eq!(result, "val: $${env.X}"); + Ok(()) + }); + } + + #[test] + fn triple_dollar_before_placeholder() { + figment::Jail::expect_with(|jail| { + jail.set_env("X", "value"); + // $$$ = $$ + $ = $ + ${env.X} expanded = $value + let result = expand_env_vars("val: $$${env.X}").unwrap(); + assert_eq!(result, "val: $value"); + Ok(()) + }); + } + + #[test] + fn hyphen_in_var_name_errors_if_undefined() { + let result = expand_env_vars("val: ${env.FOO-BAR}"); + assert!(result.is_err()); + } + + #[test] + fn nested_placeholder_syntax_in_default_is_literal() { + let result = + expand_env_vars("val: ${env.MISSING_XYZ:-prefix ${env.OTHER} suffix}").unwrap(); + assert_eq!(result, "val: prefix ${env.OTHER suffix}"); + } + + #[test] + fn default_value_containing_colon_hyphen() { + let result = expand_env_vars("val: ${env.MISSING_XYZ:-a:-b:-c}").unwrap(); + assert_eq!(result, "val: a:-b:-c"); + } + + #[test] + fn empty_input() { + assert_eq!(expand_env_vars("").unwrap(), ""); + } + + #[test] + fn standalone_dollar() { + assert_eq!(expand_env_vars("$").unwrap(), "$"); + } + + #[test] + fn whitespace_around_delimiter_uses_default() { + let result = expand_env_vars("val: ${env.VAR :- default}").unwrap(); + assert_eq!(result, "val: default"); + } + + #[test] + fn quoted_brace_in_default_causes_early_termination() { + // Known limitation: quotes don't prevent brace matching + // The } inside quotes terminates the placeholder early + let result = expand_env_vars(r#"val: ${env.MISSING_XYZ:-"}"}"#).unwrap(); + // Placeholder ends at first }, default is just ", remaining "} is literal + assert_eq!(result, r#"val: ""}"#); + } +} diff --git a/crates/apollo-mcp-server/src/lib.rs b/crates/apollo-mcp-server/src/lib.rs index be67bc158..bf90741cc 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -4,6 +4,7 @@ pub(crate) mod apps; pub(crate) mod auth; pub mod cors; pub mod custom_scalar_map; +pub mod env_expansion; pub mod errors; pub(crate) mod event; mod explorer; diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index 1baa6eec2..e9027f068 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -37,13 +37,28 @@ pub fn read_config_from_env() -> Result { .extract() } -/// Read in a config from a YAML file, filling in any missing values from the environment +/// Read in a config from a YAML file, filling in any missing values from the environment. +/// +/// Environment variable references using `${env.VAR_NAME}` syntax are expanded +/// before the YAML is parsed. #[allow(clippy::result_large_err)] pub fn read_config(yaml_path: impl AsRef) -> Result { + // Read and expand environment variables in the config content + let content = std::fs::read_to_string(yaml_path.as_ref()).map_err(|e| { + figment::Error::from(format!( + "failed to read config file '{}': {}", + yaml_path.as_ref().display(), + e + )) + })?; + + let expanded = apollo_mcp_server::env_expansion::expand_yaml(&content) + .map_err(|e| figment::Error::from(e.to_string()))?; + Figment::new() .join(apollo_common_env()) .join(Env::prefixed("APOLLO_MCP_").split(ENV_NESTED_SEPARATOR)) - .join(Yaml::file(yaml_path)) + .join(Yaml::string(&expanded)) .extract() } @@ -297,4 +312,43 @@ mod test { Ok(()) }); } + + #[test] + fn it_expands_env_vars_in_config() { + figment::Jail::expect_with(move |jail| { + let config = r#" + endpoint: ${env.TEST_EXPANDED_ENDPOINT} + "#; + let path = "config.yaml"; + + jail.create_file(path, config)?; + jail.set_env("TEST_EXPANDED_ENDPOINT", "https://expanded:4000/"); + + let config = read_config(path)?; + + assert_eq!(config.endpoint.as_str(), "https://expanded:4000/"); + Ok(()) + }); + } + + #[test] + fn it_prioritizes_apollo_mcp_env_over_expanded_vars() { + // APOLLO_MCP_* should still override expanded ${env.VAR} values + figment::Jail::expect_with(move |jail| { + let config = r#" + endpoint: ${env.MY_ENDPOINT} + "#; + let path = "config.yaml"; + + jail.create_file(path, config)?; + jail.set_env("MY_ENDPOINT", "https://from_expansion:4000/"); + jail.set_env("APOLLO_MCP_ENDPOINT", "https://from_apollo_mcp:5000/"); + + let config = read_config(path)?; + + // APOLLO_MCP_ENDPOINT wins + assert_eq!(config.endpoint.as_str(), "https://from_apollo_mcp:5000/"); + Ok(()) + }); + } } diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 0c051a175..6034c8ead 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -468,3 +468,99 @@ For example, to override the `transport.auth.servers` option, you can set the `A ```sh APOLLO_MCP_TRANSPORT__AUTH__SERVERS='[server_url_1,server_url_2]' ``` + +## Environment variable expansion + +You can reference environment variables directly in your configuration file using the `${env.VAR_NAME}` syntax. This is useful when you have existing environment variables that don't follow the `APOLLO_MCP_*` naming convention. + +```yaml title="config.yaml" +telemetry: + exporters: + tracing: + otlp: + endpoint: ${env.OTEL_EXPORTER_OTLP_ENDPOINT} +``` + +When Apollo MCP Server loads the configuration, it expands these references to their values before parsing the YAML. + +### Default values + +You can provide a fallback value using the `${env.VAR_NAME:-default}` syntax. If the environment variable is not set, the default value is used instead: + +```yaml title="config.yaml" +endpoint: ${env.GRAPHQL_ENDPOINT:-http://localhost:4000} +``` + + + +The default value is used when the variable is **unset or empty**. This matches bash and Apollo Router behavior. + + + +Default values can contain special characters, including colons, balanced braces, and even `:-`: + +```yaml title="config.yaml" +# URLs with ports work fine +endpoint: ${env.API_URL:-http://localhost:4000} + +# JSON-like defaults with balanced braces work +metadata: "${env.DEFAULT_META:-{\"version\":\"1.0\"}}" + +# Multiple :- in default value (only the first one delimits) +value: ${env.MY_VAR:-a:-b:-c} # default is "a:-b:-c" +``` + + + +**Nested references are not expanded.** Default values are treated as literal text. If your default contains `${env.OTHER}`, it will appear literally in the output, not be expanded: + +```yaml +# If MISSING is unset, this becomes literally: "prefix ${env.OTHER} suffix" +value: ${env.MISSING:-prefix ${env.OTHER} suffix} +``` + + + + + +**Quotes don't escape closing braces.** Brace matching in default values is purely depth-based. A `}` inside quotes will still terminate the placeholder: + +```yaml +# This does NOT work as expected - the } inside quotes ends the placeholder early +value: ${env.VAR:-"}"} # Results in: ""} (not "}") +``` + + + + + +`APOLLO_MCP_*` environment variables still take precedence over expanded values in the config file. For example, if you set both `${env.MY_ENDPOINT}` in the config and `APOLLO_MCP_ENDPOINT` as an environment variable, `APOLLO_MCP_ENDPOINT` wins. + + + +### Special characters + +If your environment variable value contains YAML special characters (colons, brackets, quotes), wrap the expanded value in quotes: + +```yaml title="config.yaml" +# Safe for values that might contain special characters +description: "${env.MY_DESCRIPTION}" +``` + +### Escaping + +To include a literal `${env.VAR}` in your configuration without expansion, escape the dollar sign by doubling it: + +```yaml title="config.yaml" +# Becomes: ${env.NOT_EXPANDED} after loading +template: "$${env.NOT_EXPANDED}" +``` + +### Error handling + +If a referenced environment variable is not defined, Apollo MCP Server fails to start with a clear error message: + +``` +Error: undefined environment variable 'OTEL_EXPORTER_OTLP_ENDPOINT' referenced in configuration +``` +