Skip to main content

vtcode_core/mcp/
utils.rs

1//! Utility functions for MCP client operations.
2
3use anyhow::{Context, Result};
4use chrono::Local;
5use hashbrown::HashMap;
6use iana_time_zone::get_timezone;
7use rmcp::model::Implementation;
8use rmcp_reqwest::header::{HeaderMap, HeaderName, HeaderValue};
9use serde_json::{Map, Value};
10use std::env;
11#[cfg(test)]
12use std::sync::{LazyLock, Mutex};
13use tracing::{debug, warn};
14
15#[cfg(test)]
16static TEST_ENV_OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
17    LazyLock::new(|| Mutex::new(HashMap::new()));
18
19#[cfg(test)]
20fn get_test_env_override(key: &str) -> Option<Option<String>> {
21    TEST_ENV_OVERRIDES
22        .lock()
23        .ok()
24        .and_then(|map| map.get(key).cloned())
25}
26
27fn read_env_var(key: &str) -> Option<String> {
28    #[cfg(test)]
29    if let Some(override_value) = get_test_env_override(key) {
30        return override_value;
31    }
32
33    env::var(key).ok()
34}
35
36#[cfg(test)]
37pub(crate) fn set_test_env_override(key: &str, value: Option<&str>) {
38    if let Ok(mut map) = TEST_ENV_OVERRIDES.lock() {
39        map.insert(key.to_owned(), value.map(ToOwned::to_owned));
40    }
41}
42
43#[cfg(test)]
44pub(crate) fn clear_test_env_override(key: &str) {
45    if let Ok(mut map) = TEST_ENV_OVERRIDES.lock() {
46        map.remove(key);
47    }
48}
49
50/// Build the standard Implementation struct for vtcode MCP client.
51///
52/// This ensures consistent client identification across all MCP connections.
53pub fn build_client_implementation() -> Implementation {
54    Implementation::new("vtcode", env!("CARGO_PKG_VERSION"))
55}
56
57/// Environment variable for explicit local timezone override.
58pub const LOCAL_TIMEZONE_ENV_VAR: &str = "VTCODE_LOCAL_TIMEZONE";
59/// Standard TZ environment variable fallback.
60pub const TZ_ENV_VAR: &str = "TZ";
61/// Argument name for timezone injection.
62pub const TIMEZONE_ARGUMENT: &str = "timezone";
63
64/// Ensure a timezone argument is present when required by the schema.
65pub fn ensure_timezone_argument(
66    arguments: &mut Map<String, Value>,
67    requires_timezone: bool,
68) -> Result<()> {
69    if !requires_timezone {
70        return Ok(());
71    }
72
73    let timezone = detect_local_timezone()
74        .context("failed to determine a default timezone for MCP tool invocation")?;
75    debug!("Injecting local timezone '{timezone}' for MCP tool call");
76    arguments
77        .entry(TIMEZONE_ARGUMENT.to_string())
78        .or_insert_with(|| Value::String(timezone));
79    Ok(())
80}
81
82/// Detect the local timezone using environment variables or system detection.
83pub fn detect_local_timezone() -> Result<String> {
84    if let Some(value) = read_env_var(LOCAL_TIMEZONE_ENV_VAR) {
85        let trimmed = value.trim();
86        if !trimmed.is_empty() {
87            return Ok(trimmed.to_string());
88        }
89    }
90
91    if let Some(value) = read_env_var(TZ_ENV_VAR) {
92        let trimmed = value.trim();
93        if !trimmed.is_empty() {
94            return Ok(trimmed.to_string());
95        }
96    }
97
98    match get_timezone() {
99        Ok(timezone) => Ok(timezone),
100        Err(err) => {
101            let fallback = Local::now().format("%:z").to_string();
102            warn!(
103                "Falling back to numeric offset '{fallback}' after failing to resolve IANA timezone: {err}"
104            );
105            Ok(fallback)
106        }
107    }
108}
109
110/// Check if a JSON schema requires a specific field.
111pub fn schema_requires_field(schema: &Value, field: &str) -> bool {
112    match schema {
113        Value::Object(map) => {
114            if map
115                .get("required")
116                .and_then(Value::as_array)
117                .map(|items| items.iter().any(|item| item.as_str() == Some(field)))
118                .unwrap_or(false)
119            {
120                return true;
121            }
122
123            for keyword in ["allOf", "anyOf", "oneOf"] {
124                if let Some(subschemas) = map.get(keyword).and_then(Value::as_array)
125                    && subschemas
126                        .iter()
127                        .any(|subschema| schema_requires_field(subschema, field))
128                {
129                    return true;
130                }
131            }
132
133            if let Some(items) = map.get("items")
134                && schema_requires_field(items, field)
135            {
136                return true;
137            }
138
139            if let Some(properties) = map.get("properties").and_then(Value::as_object)
140                && let Some(property_schema) = properties.get(field)
141                && schema_requires_field(property_schema, field)
142            {
143                return true;
144            }
145
146            false
147        }
148        _ => false,
149    }
150}
151
152/// Build HTTP headers from static and environment-based configuration.
153pub fn build_headers(
154    static_headers: &HashMap<String, String>,
155    env_headers: &HashMap<String, String>,
156) -> HeaderMap {
157    let mut map = HeaderMap::new();
158
159    for (key, value) in static_headers {
160        match HeaderName::from_bytes(key.as_bytes()) {
161            Ok(name) => match HeaderValue::from_str(value) {
162                Ok(header_value) => {
163                    map.insert(name, header_value);
164                }
165                Err(err) => {
166                    warn!(
167                        header = key.as_str(),
168                        error = %err,
169                        "Skipping MCP HTTP header with invalid value"
170                    );
171                }
172            },
173            Err(err) => {
174                warn!(
175                    header = key.as_str(),
176                    error = %err,
177                    "Skipping MCP HTTP header with invalid name"
178                );
179            }
180        }
181    }
182
183    for (key, env_var) in env_headers {
184        match read_env_var(env_var) {
185            Some(value) if !value.trim().is_empty() => match HeaderName::from_bytes(key.as_bytes())
186            {
187                Ok(name) => match HeaderValue::from_str(&value) {
188                    Ok(header_value) => {
189                        map.insert(name, header_value);
190                    }
191                    Err(err) => {
192                        warn!(
193                            header = key.as_str(),
194                            env_var = env_var.as_str(),
195                            error = %err,
196                            "Skipping MCP HTTP header from environment with invalid value"
197                        );
198                    }
199                },
200                Err(err) => {
201                    warn!(
202                        header = key.as_str(),
203                        env_var = env_var.as_str(),
204                        error = %err,
205                        "Skipping MCP HTTP header from environment with invalid name"
206                    );
207                }
208            },
209            Some(_) => {
210                debug!(
211                    header = key.as_str(),
212                    env_var = env_var.as_str(),
213                    "Skipping MCP HTTP header from environment because the value is empty"
214                );
215            }
216            None => {
217                debug!(
218                    header = key.as_str(),
219                    env_var = env_var.as_str(),
220                    "Skipping MCP HTTP header from environment because the variable is unset"
221                );
222            }
223        }
224    }
225
226    map
227}