1use 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
50pub fn build_client_implementation() -> Implementation {
54 Implementation::new("vtcode", env!("CARGO_PKG_VERSION"))
55}
56
57pub const LOCAL_TIMEZONE_ENV_VAR: &str = "VTCODE_LOCAL_TIMEZONE";
59pub const TZ_ENV_VAR: &str = "TZ";
61pub const TIMEZONE_ARGUMENT: &str = "timezone";
63
64pub 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
82pub 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
110pub 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
152pub 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}