use anyhow::{Context, Result, bail};
use chrono::Local;
use hashbrown::HashMap;
use iana_time_zone::get_timezone;
use rmcp::model::{ClientCapabilities, Implementation};
use rmcp_reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::env;
#[cfg(test)]
use std::sync::{LazyLock, Mutex};
use tracing::{debug, warn};
use vtcode_config::mcp::{McpUiAppsTier, McpUiConfig};
use crate::types::{MCP_UI_EXTENSION_ID, MCP_UI_RESOURCE_MIME_TYPE};
#[cfg(test)]
static TEST_ENV_OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[cfg(test)]
fn get_test_env_override(key: &str) -> Option<Option<String>> {
TEST_ENV_OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
}
fn read_env_var(key: &str) -> Option<String> {
#[cfg(test)]
if let Some(override_value) = get_test_env_override(key) {
return override_value;
}
env::var(key).ok()
}
#[cfg(test)]
pub(crate) fn set_test_env_override(key: &str, value: Option<&str>) {
if let Ok(mut map) = TEST_ENV_OVERRIDES.lock() {
drop(map.insert(key.to_owned(), value.map(ToOwned::to_owned)));
}
}
#[cfg(test)]
pub(crate) fn clear_test_env_override(key: &str) {
if let Ok(mut map) = TEST_ENV_OVERRIDES.lock() {
drop(map.remove(key));
}
}
pub(crate) fn build_client_implementation() -> Implementation {
Implementation::new("vtcode", env!("CARGO_PKG_VERSION"))
}
pub(crate) fn apply_ui_extension_gate(capabilities: &mut ClientCapabilities, advertise: bool) {
if !advertise {
return;
}
let _ = capabilities
.extensions
.get_or_insert_with(BTreeMap::new)
.entry(MCP_UI_EXTENSION_ID.to_owned())
.or_insert_with(|| {
Map::from_iter([(
"mimeTypes".to_owned(),
Value::Array(vec![Value::String(MCP_UI_RESOURCE_MIME_TYPE.to_owned())]),
)])
});
}
pub(crate) fn reject_unsupported_ui_apps_tier(ui: &McpUiConfig) -> Result<()> {
if ui.apps == McpUiAppsTier::Interactive {
bail!(
"mcp.ui.apps = \"interactive\" is not supported yet: interactive MCP Apps require a sandboxed webview \
host. Use \"off\" (default) or \"static\" until then."
);
}
Ok(())
}
pub(crate) const LOCAL_TIMEZONE_ENV_VAR: &str = "VTCODE_LOCAL_TIMEZONE";
pub(crate) const TZ_ENV_VAR: &str = "TZ";
pub(crate) const TIMEZONE_ARGUMENT: &str = "timezone";
pub(crate) fn ensure_timezone_argument(arguments: &mut Map<String, Value>, requires_timezone: bool) -> Result<()> {
if !requires_timezone {
return Ok(());
}
let timezone = detect_local_timezone().context("failed to determine a default timezone for MCP tool invocation")?;
debug!("Injecting local timezone '{timezone}' for MCP tool call");
let _entry = arguments
.entry(TIMEZONE_ARGUMENT.to_string())
.or_insert_with(|| Value::String(timezone));
Ok(())
}
pub(crate) fn detect_local_timezone() -> Result<String> {
if let Some(value) = read_env_var(LOCAL_TIMEZONE_ENV_VAR) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
if let Some(value) = read_env_var(TZ_ENV_VAR) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
match get_timezone() {
Ok(timezone) => Ok(timezone),
Err(err) => {
let fallback = Local::now().format("%:z").to_string();
warn!("Falling back to numeric offset '{fallback}' after failing to resolve IANA timezone: {err}");
Ok(fallback)
}
}
}
pub(crate) fn schema_requires_field(schema: &Value, field: &str) -> bool {
match schema {
Value::Object(map) => {
if map
.get("required")
.and_then(Value::as_array)
.map(|items| items.iter().any(|item| item.as_str() == Some(field)))
.unwrap_or(false)
{
return true;
}
for keyword in ["allOf", "anyOf", "oneOf"] {
if let Some(subschemas) = map.get(keyword).and_then(Value::as_array)
&& subschemas.iter().any(|subschema| schema_requires_field(subschema, field))
{
return true;
}
}
if let Some(items) = map.get("items")
&& schema_requires_field(items, field)
{
return true;
}
if let Some(properties) = map.get("properties").and_then(Value::as_object)
&& let Some(property_schema) = properties.get(field)
&& schema_requires_field(property_schema, field)
{
return true;
}
false
}
_ => false,
}
}
pub(crate) fn build_headers(
static_headers: &HashMap<String, String>,
env_headers: &HashMap<String, String>,
) -> HeaderMap {
let mut map = HeaderMap::new();
for (key, value) in static_headers {
match HeaderName::from_bytes(key.as_bytes()) {
Ok(name) => match HeaderValue::from_str(value) {
Ok(header_value) => {
drop(map.insert(name, header_value));
}
Err(err) => {
warn!(
header = key.as_str(),
error = %err,
"Skipping MCP HTTP header with invalid value"
);
}
},
Err(err) => {
warn!(
header = key.as_str(),
error = %err,
"Skipping MCP HTTP header with invalid name"
);
}
}
}
for (key, env_var) in env_headers {
match read_env_var(env_var) {
Some(value) if !value.trim().is_empty() => match HeaderName::from_bytes(key.as_bytes()) {
Ok(name) => match HeaderValue::from_str(&value) {
Ok(header_value) => {
drop(map.insert(name, header_value));
}
Err(err) => {
warn!(
header = key.as_str(),
env_var = env_var.as_str(),
error = %err,
"Skipping MCP HTTP header from environment with invalid value"
);
}
},
Err(err) => {
warn!(
header = key.as_str(),
env_var = env_var.as_str(),
error = %err,
"Skipping MCP HTTP header from environment with invalid name"
);
}
},
Some(_) => {
debug!(
header = key.as_str(),
env_var = env_var.as_str(),
"Skipping MCP HTTP header from environment because the value is empty"
);
}
None => {
debug!(
header = key.as_str(),
env_var = env_var.as_str(),
"Skipping MCP HTTP header from environment because the variable is unset"
);
}
}
}
map
}
#[cfg(test)]
mod tests {
use super::{apply_ui_extension_gate, reject_unsupported_ui_apps_tier};
use rmcp::model::ClientCapabilities;
use serde_json::Value;
use vtcode_config::mcp::{McpUiAppsTier, McpUiConfig};
#[test]
fn ui_extension_gate_advertises_exact_settings_object() {
let mut capabilities = ClientCapabilities::default();
apply_ui_extension_gate(&mut capabilities, true);
let extensions = capabilities.extensions.as_ref().expect("extensions advertised");
let settings = extensions.get("io.modelcontextprotocol/ui").expect("ui extension");
assert_eq!(
settings.get("mimeTypes"),
Some(&Value::Array(vec![Value::String("text/html;profile=mcp-app".to_string())]))
);
assert_eq!(settings.len(), 1);
let payload = serde_json::to_value(&capabilities).expect("serialize capabilities");
assert_eq!(
payload["extensions"]["io.modelcontextprotocol/ui"]["mimeTypes"],
serde_json::json!(["text/html;profile=mcp-app"])
);
}
#[test]
fn ui_extension_gate_is_noop_when_not_advertised() {
let mut capabilities = ClientCapabilities::default();
apply_ui_extension_gate(&mut capabilities, false);
assert_eq!(capabilities.extensions, None);
let mut capabilities = ClientCapabilities::default();
apply_ui_extension_gate(&mut capabilities, true);
apply_ui_extension_gate(&mut capabilities, false);
assert!(capabilities.extensions.is_some());
}
#[test]
fn ui_extension_gate_preserves_preexisting_settings() {
use std::collections::BTreeMap;
let mut capabilities = ClientCapabilities::default();
capabilities.extensions = Some(BTreeMap::from([(
"io.modelcontextprotocol/ui".to_string(),
serde_json::Map::from_iter([
("mimeTypes".to_string(), serde_json::json!(["text/html;profile=mcp-app"])),
("future".to_string(), Value::Bool(true)),
]),
)]));
apply_ui_extension_gate(&mut capabilities, true);
let extensions = capabilities.extensions.as_ref().expect("extensions");
let settings = extensions.get("io.modelcontextprotocol/ui").expect("ui extension");
assert_eq!(settings.get("future"), Some(&Value::Bool(true)));
assert_eq!(settings.len(), 2);
}
#[test]
fn ui_apps_tier_rejection_fires_only_on_interactive() {
for tier in [McpUiAppsTier::Off, McpUiAppsTier::Static] {
let ui = McpUiConfig { apps: tier, ..McpUiConfig::default() };
reject_unsupported_ui_apps_tier(&ui).expect("supported tier");
}
let ui = McpUiConfig {
apps: McpUiAppsTier::Interactive,
..McpUiConfig::default()
};
let err = reject_unsupported_ui_apps_tier(&ui).expect_err("interactive rejected");
assert!(err.to_string().contains("interactive"), "{err:?}");
}
}