Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::DateTime;
|
||||
use handlebars::{get_arguments, render_template};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use serde::Deserialize;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::mcp::{JSONMCPServer, JSONTransportType};
|
||||
|
||||
use crate::{
|
||||
ai::mcp::{
|
||||
templatable::{JsonTemplate, TemplatableMCPServer, TemplateVariable},
|
||||
templatable_installation::{TemplatableMCPServerInstallation, VariableType, VariableValue},
|
||||
},
|
||||
server::datetime_ext::DateTimeExt,
|
||||
};
|
||||
|
||||
/// Normalize MCP JSON input to ensure it has a server name wrapper.
|
||||
///
|
||||
/// If the JSON is a single server definition (has `command` or `url` at the top level),
|
||||
/// wrap it with a generated name. Otherwise, return the JSON as-is.
|
||||
///
|
||||
/// Note: When returning the original JSON, we preserve the exact input string.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn normalize_mcp_json(json_str: &str) -> serde_json::Result<String> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json_str.trim();
|
||||
let json_for_parsing = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(&json_for_parsing)?;
|
||||
|
||||
// Check if this is a single server definition (has command or url at top level)
|
||||
let is_single_server = value.get("command").is_some() || value.get("url").is_some();
|
||||
|
||||
if is_single_server {
|
||||
let server_name = uuid::Uuid::new_v4().to_string();
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(server_name, value);
|
||||
Ok(serde_json::Value::Object(map).to_string())
|
||||
} else {
|
||||
Ok(json_str.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A single entry under `[mcp_servers.<name>]` in a Codex TOML file.
|
||||
///
|
||||
/// Codex servers are either STDIO (discriminated by a required `command` field)
|
||||
/// or streamable HTTP (discriminated by a required `url` field).
|
||||
/// See https://developers.openai.com/codex/mcp/ for more details on Codex's MCP configuration spec.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum CodexServerEntry {
|
||||
/// A local process server launched over stdin/stdout.
|
||||
Stdio {
|
||||
/// The command that starts the server. Acts as the discriminant.
|
||||
command: String,
|
||||
/// Arguments to pass to the server command.
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
/// Literal key=value environment variables set on the server process.
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
/// Names of environment variables to forward from the calling shell.
|
||||
/// Each name `N` is lowered to a `"${N}"` placeholder in the merged `env` map.
|
||||
/// Explicit `env` values win over `env_vars` placeholders on collision.
|
||||
#[serde(default)]
|
||||
env_vars: Vec<String>,
|
||||
/// Working directory for the server process.
|
||||
/// Mapped to `working_directory` in Warp JSON.
|
||||
cwd: Option<String>,
|
||||
},
|
||||
/// A remote server reached over streamable HTTP.
|
||||
Http {
|
||||
/// The server URL. Acts as the discriminant.
|
||||
url: String,
|
||||
/// Name of an environment variable holding a bearer token.
|
||||
/// Lowered to `Authorization: "Bearer ${VAR}"` in the headers map.
|
||||
bearer_token_env_var: Option<String>,
|
||||
/// Static header values sent verbatim (e.g. `X-Client = "codex"`).
|
||||
/// Wins over `env_http_headers` on collision.
|
||||
#[serde(default)]
|
||||
http_headers: HashMap<String, String>,
|
||||
/// Map of header names to environment variable names.
|
||||
/// Each entry becomes `header = "${var_name}"` in the headers map.
|
||||
#[serde(default)]
|
||||
env_http_headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl From<CodexServerEntry> for JSONTransportType {
|
||||
fn from(entry: CodexServerEntry) -> Self {
|
||||
match entry {
|
||||
CodexServerEntry::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
// Build merged env: env_vars placeholders first.
|
||||
let mut merged_env: HashMap<String, String> = env_vars
|
||||
.into_iter()
|
||||
.map(|var_name| (var_name.clone(), format!("${{{var_name}}}")))
|
||||
.collect();
|
||||
// Explicit env wins over env_vars placeholders on collision.
|
||||
for (k, v) in env {
|
||||
merged_env.insert(k, v);
|
||||
}
|
||||
|
||||
JSONTransportType::CLIServer {
|
||||
command,
|
||||
args,
|
||||
env: merged_env,
|
||||
working_directory: cwd,
|
||||
}
|
||||
}
|
||||
CodexServerEntry::Http {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
// Build merged headers.
|
||||
// Merge order (later wins): bearer < env_http_headers < http_headers.
|
||||
let mut merged_headers: HashMap<String, String> = HashMap::new();
|
||||
|
||||
if let Some(var_name) = bearer_token_env_var {
|
||||
merged_headers.insert(
|
||||
"Authorization".to_owned(),
|
||||
format!("Bearer ${{{var_name}}}"),
|
||||
);
|
||||
}
|
||||
for (header, var_name) in env_http_headers {
|
||||
merged_headers.insert(header, format!("${{{var_name}}}"));
|
||||
}
|
||||
for (header, value) in http_headers {
|
||||
merged_headers.insert(header, value);
|
||||
}
|
||||
|
||||
JSONTransportType::SSEServer {
|
||||
url,
|
||||
headers: merged_headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes the contents of a Codex `config.toml` into a JSON string
|
||||
/// compatible with `ParsedTemplatableMCPServerResult::from_user_json`.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn normalize_codex_toml_to_json(file_contents: &str) -> Result<String, anyhow::Error> {
|
||||
// Parse into a raw Value first so we can handle per-entry deserialization failures
|
||||
// gracefully. Using HashMap<String, CodexServerEntry> directly would cause the entire
|
||||
// parse to fail if any single entry matches neither Stdio nor Http.
|
||||
let raw: toml::Value = toml::from_str(file_contents)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse Codex TOML: {e}"))?;
|
||||
|
||||
let out_servers: HashMap<String, JSONMCPServer> = raw
|
||||
.get("mcp_servers")
|
||||
.and_then(|v| v.as_table())
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter_map(|(name, val)| {
|
||||
val.clone()
|
||||
.try_into::<CodexServerEntry>()
|
||||
.ok()
|
||||
.map(|entry| {
|
||||
(
|
||||
name.clone(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::from(entry),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let wrapped = serde_json::json!({ "mcp_servers": out_servers });
|
||||
serde_json::to_string(&wrapped)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to serialize normalized Codex TOML as JSON: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedTemplatableMCPServerResult {
|
||||
pub templatable_mcp_server: TemplatableMCPServer,
|
||||
pub templatable_mcp_server_installation: Option<TemplatableMCPServerInstallation>,
|
||||
}
|
||||
|
||||
/// Extracts a field from JSON as a HashMap<String, String>.
|
||||
fn extract_string_map(
|
||||
json_value: &serde_json::Value,
|
||||
field_name: &str,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
json_value
|
||||
.get(field_name)
|
||||
.and_then(|value| value.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(key, value)| value.as_str().map(|s| (key.to_owned(), s.to_owned())))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a HashMap's keys to template placeholders (e.g., `{{key}}`) and
|
||||
/// inserts the result back into the JSON value under the given field name.
|
||||
fn templatize_field(
|
||||
json_value: &mut serde_json::Value,
|
||||
field_name: &str,
|
||||
map: &HashMap<String, String>,
|
||||
) {
|
||||
let templated_value = serde_json::Value::Object(
|
||||
map.keys()
|
||||
.map(|key| {
|
||||
(
|
||||
key.to_owned(),
|
||||
serde_json::Value::String(format!("{{{{{key}}}}}")),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(object) = json_value.as_object_mut() {
|
||||
object.insert(field_name.to_owned(), templated_value);
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTemplatableMCPServerResult {
|
||||
/// Parses MCP servers from a config file (e.g. `~/.claude.json`, `.mcp.json`).
|
||||
///
|
||||
/// Unlike [`from_user_json`], this only recognises servers under a known
|
||||
/// wrapper key (`mcpServers`, `servers`, etc.) and will **not** fall back to
|
||||
/// treating every top-level key as a server name.
|
||||
pub fn from_config_file_json(json: &str) -> serde_json::Result<Vec<Self>> {
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
let template_jsons = TemplatableMCPServer::find_template_map_strict(&config);
|
||||
|
||||
Ok(template_jsons
|
||||
.iter()
|
||||
.map(|(name, json)| Self::parse_result(name, json))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Parses the user json and returns a vector of ParsedTemplatableMCPServerResult
|
||||
/// If the json is invalid, returns an error
|
||||
/// It's up to the caller to handle cases where there are an unexpected number of servers
|
||||
pub fn from_user_json(json: &str) -> serde_json::Result<Vec<Self>> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
let template_jsons = TemplatableMCPServer::find_template_map(config)?;
|
||||
|
||||
Ok(template_jsons
|
||||
.iter()
|
||||
.map(|(name, json)| Self::parse_result(name, json))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Parses a single MCP config
|
||||
/// Returns a ParsedTemplatableMCPServerResult
|
||||
/// If the json is invalid, returns an error
|
||||
pub(crate) fn parse_result(name: &str, json_value: &serde_json::Value) -> Self {
|
||||
// We need to clone the json value to avoid modifying the original value
|
||||
// json_value needs to be mutable to redact the env/header values
|
||||
let mut json_value = json_value.clone();
|
||||
|
||||
let description: Option<String> = json_value
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str().map(|s| s.to_owned()));
|
||||
|
||||
// Extract env and headers, then convert their values to template placeholders
|
||||
let env = extract_string_map(&json_value, "env");
|
||||
if let Some(ref env) = env {
|
||||
templatize_field(&mut json_value, "env", env);
|
||||
}
|
||||
|
||||
let headers = extract_string_map(&json_value, "headers");
|
||||
if let Some(ref headers) = headers {
|
||||
templatize_field(&mut json_value, "headers", headers);
|
||||
}
|
||||
|
||||
let raw_json = json_value.to_string();
|
||||
let arguments = get_arguments(&raw_json);
|
||||
let variables = arguments
|
||||
.iter()
|
||||
.map(|argument| TemplateVariable {
|
||||
key: argument.clone(),
|
||||
allowed_values: None,
|
||||
})
|
||||
.collect::<Vec<TemplateVariable>>();
|
||||
|
||||
// Each template_json is the nested config for a single MCP server
|
||||
// We need to re-wrap it in a top level object so that we can
|
||||
// reuse from_user_json to read it later
|
||||
let normalized_map =
|
||||
serde_json::Map::from_iter(vec![(name.to_owned(), json_value.clone())]);
|
||||
let normalized_value = serde_json::Value::Object(normalized_map);
|
||||
let normalized_json =
|
||||
serde_json::to_string_pretty(&normalized_value).unwrap_or(normalized_value.to_string());
|
||||
|
||||
let templatable_mcp_server = TemplatableMCPServer {
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
name: name.to_owned(),
|
||||
description,
|
||||
template: JsonTemplate {
|
||||
json: normalized_json,
|
||||
variables,
|
||||
},
|
||||
version: DateTime::now().timestamp(),
|
||||
gallery_data: None,
|
||||
};
|
||||
|
||||
// Combine env and headers into a single map for variable lookup
|
||||
let combined_values: HashMap<String, String> = env
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(headers.clone().unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
// determine if all variables are present in env or headers
|
||||
let all_variables_present = templatable_mcp_server
|
||||
.template
|
||||
.variables
|
||||
.iter()
|
||||
.all(|variable| combined_values.contains_key(&variable.key));
|
||||
|
||||
let templatable_mcp_server_installation = match all_variables_present {
|
||||
true => {
|
||||
let variable_values = combined_values
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
VariableValue {
|
||||
variable_type: VariableType::Text,
|
||||
value,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(TemplatableMCPServerInstallation::new(
|
||||
uuid::Uuid::new_v4(),
|
||||
templatable_mcp_server.clone(),
|
||||
variable_values,
|
||||
))
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
|
||||
ParsedTemplatableMCPServerResult {
|
||||
templatable_mcp_server,
|
||||
templatable_mcp_server_installation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_json(installation: &TemplatableMCPServerInstallation) -> String {
|
||||
// Collapse the variable values into a flat hashmap for easy replacement
|
||||
fn variable_value_to_string(variable_value: &VariableValue) -> String {
|
||||
match variable_value.variable_type {
|
||||
VariableType::Text => variable_value.value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
let variable_values_strings: HashMap<String, String> = installation
|
||||
.variable_values()
|
||||
.iter()
|
||||
.map(|(key, variable_value)| (key.clone(), variable_value_to_string(variable_value)))
|
||||
.collect();
|
||||
|
||||
render_template(installation.template_json(), &variable_values_strings)
|
||||
}
|
||||
|
||||
pub fn prettify_json(json: &str) -> String {
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
|
||||
serde_json::to_string_pretty(&value).unwrap_or(json.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parsing_tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user