use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::agent_identity::{Secret, SecretRef};
use crate::error::{NewtError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportKind {
#[default]
Stdio,
Sse,
Http,
}
impl TransportKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Stdio => "stdio",
Self::Sse => "sse",
Self::Http => "http",
}
}
#[must_use]
pub fn from_keyword(s: &str) -> Option<Self> {
match s {
"stdio" => Some(Self::Stdio),
"sse" => Some(Self::Sse),
"http" => Some(Self::Http),
_ => None,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum McpTrust {
#[default]
Trusted,
Untrusted,
}
pub fn resolve_secret_under_trust(value: &SecretValue, trust: McpTrust) -> Result<Secret> {
match trust {
McpTrust::Trusted => value.resolve(),
McpTrust::Untrusted => match value {
SecretValue::Literal(s) => Ok(Secret::new(s.clone())),
SecretValue::Ref(_) => Err(NewtError::Config(
"a discovered (untrusted) MCP config source (a project `.mcp.json` or \
`~/.claude.json`) may not use a `{ env | file | cmd }` secret reference — \
only newt-owned config (`config.toml`, `~/.newt/mcp.toml`) may name a command \
to run or a file to read. Adopt the server as trusted with `newt mcp import` (the \
untrusted-server refusal names the exact command)."
.to_string(),
)),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdmissionDenied {
Disabled,
UntrustedNotApproved {
server: String,
origin: Option<McpOrigin>,
},
}
impl std::fmt::Display for AdmissionDenied {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => write!(f, "disabled (`enabled = false`)"),
Self::UntrustedNotApproved { server, origin } => write!(
f,
"untrusted MCP config not approved — a discovered `.mcp.json` / \
`~/.claude.json` / project overlay may not spawn or dial without an \
approval recorded outside the repo; {}",
import_fix_hint(server, origin.as_ref())
),
}
}
}
#[must_use]
pub fn is_portable_import_name(name: &str) -> bool {
let mut chars = name.chars();
let safe_shape = name.len() <= 128
&& chars.next().is_some_and(|ch| ch.is_ascii_alphanumeric())
&& chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'));
let unambiguous_namespace = runtime_server_prefix_is_unambiguous(name, false)
&& runtime_server_prefix_is_unambiguous(name, true);
let stem = name
.split('.')
.next()
.unwrap_or_default()
.to_ascii_uppercase();
let windows_device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| stem
.strip_prefix("COM")
.or_else(|| stem.strip_prefix("LPT"))
.is_some_and(|number| {
matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
});
safe_shape && unambiguous_namespace && !windows_device && !name.ends_with('.')
}
#[must_use]
pub fn shell_quote_arg(arg: &str) -> String {
if arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._/~+-".contains(c))
{
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
}
#[must_use]
pub fn import_fix_hint(server: &str, origin: Option<&McpOrigin>) -> String {
if !is_portable_import_name(server) {
return format!(
"server {server:?} cannot be imported under that name; rename it in its source file"
);
}
match origin {
Some(McpOrigin::ClaudeUser) => {
format!("adopt it as trusted with `newt mcp import --from-claude --name {server}`")
}
Some(McpOrigin::File(path)) => format!(
"adopt it as trusted with `newt mcp import {} --name {server}`",
shell_quote_arg(&path.to_string_lossy())
),
None => format!(
"adopt server {server:?} as trusted with `newt mcp import` on the file that \
defines it (`newt mcp list` shows where each server came from)"
),
}
}
#[derive(Debug, Clone, Copy)]
pub struct AdmittedServer<'a> {
entry: &'a McpServerEntry,
}
impl<'a> AdmittedServer<'a> {
pub fn entry(&self) -> &'a McpServerEntry {
self.entry
}
}
pub fn admit(entry: &McpServerEntry) -> std::result::Result<AdmittedServer<'_>, AdmissionDenied> {
if !entry.enabled {
return Err(AdmissionDenied::Disabled);
}
match entry.trust {
McpTrust::Trusted => Ok(AdmittedServer { entry }),
McpTrust::Untrusted => Err(AdmissionDenied::UntrustedNotApproved {
server: entry.name.clone(),
origin: entry.origin.clone(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SecretValue {
Literal(String),
Ref(SecretRef),
}
impl SecretValue {
pub fn literal(value: impl Into<String>) -> Self {
Self::Literal(value.into())
}
#[must_use]
pub fn as_literal(&self) -> Option<&str> {
match self {
Self::Literal(s) => Some(s),
Self::Ref(_) => None,
}
}
pub fn resolve(&self) -> Result<Secret> {
match self {
Self::Literal(s) => Ok(Secret::new(interpolate(s)?)),
Self::Ref(r) => r.resolve()?.ok_or_else(|| {
NewtError::Config(
"MCP secret reference resolved to nothing (missing env var, empty file, \
or empty command output)"
.to_string(),
)
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InterpToken {
Env(String),
File(String),
Cmd(String),
}
impl InterpToken {
fn to_secret_ref(&self) -> SecretRef {
match self {
Self::Env(v) => SecretRef {
env: Some(v.clone()),
..Default::default()
},
Self::File(p) => SecretRef {
file: Some(p.clone()),
..Default::default()
},
Self::Cmd(c) => SecretRef {
cmd: Some(c.clone()),
..Default::default()
},
}
}
fn describe(&self) -> String {
match self {
Self::Env(v) => format!("${{env:{v}}}"),
Self::File(p) => format!("${{file:{p}}}"),
Self::Cmd(c) => format!("${{cmd:{c}}}"),
}
}
}
fn is_env_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn classify_token(inner: &str) -> Option<InterpToken> {
match inner.split_once(':') {
Some(("env", rest)) => Some(InterpToken::Env(rest.to_string())),
Some(("file", rest)) => Some(InterpToken::File(rest.to_string())),
Some(("cmd", rest)) => Some(InterpToken::Cmd(rest.to_string())),
Some(_) => None,
None => is_env_identifier(inner).then(|| InterpToken::Env(inner.to_string())),
}
}
fn interpolate_with<F>(template: &str, resolve: F) -> Result<String>
where
F: Fn(&InterpToken) -> Result<String>,
{
if !template.contains("${") {
return Ok(template.to_string());
}
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("${") {
if start >= 1 && rest.as_bytes()[start - 1] == b'$' {
out.push_str(&rest[..start - 1]);
out.push_str("${");
rest = &rest[start + 2..];
continue;
}
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
return Err(NewtError::Config(
"unterminated `${` in an MCP env/header value (missing closing `}`)".to_string(),
));
};
let inner = &after[..end];
match classify_token(inner) {
Some(token) => out.push_str(&resolve(&token)?),
None => {
out.push_str("${");
out.push_str(inner);
out.push('}');
}
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(out)
}
fn resolve_token_live(token: &InterpToken) -> Result<String> {
match token.to_secret_ref().resolve()? {
Some(secret) => Ok(secret.expose().to_string()),
None => Err(NewtError::Config(format!(
"{} resolved to nothing (missing env var, empty file, or empty command output)",
token.describe()
))),
}
}
pub fn interpolate(template: &str) -> Result<String> {
interpolate_with(template, resolve_token_live)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpServerEntry {
#[serde(default)]
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, rename = "type")]
pub transport: TransportKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub login_argv: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, SecretValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, SecretValue>,
#[serde(
default,
alias = "requestTimeoutSecs",
skip_serializing_if = "Option::is_none"
)]
pub request_timeout_secs: Option<u64>,
#[serde(skip)]
pub trust: McpTrust,
#[serde(skip)]
pub origin: Option<McpOrigin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpOrigin {
ClaudeUser,
File(PathBuf),
}
impl McpServerEntry {
pub fn operator_login_argv(&self) -> Option<&[String]> {
(self.enabled
&& self.trust == McpTrust::Trusted
&& self.transport == TransportKind::Stdio
&& self
.login_argv
.first()
.is_some_and(|program| !program.trim().is_empty()))
.then_some(self.login_argv.as_slice())
}
pub fn is_valid(&self) -> bool {
match self.transport {
TransportKind::Stdio => self.command.is_some(),
TransportKind::Sse | TransportKind::Http => self.url.is_some(),
}
}
}
pub(crate) fn validate_entry_for_write(entry: &McpServerEntry) -> crate::error::Result<()> {
if entry.name.trim().is_empty() {
return Err(crate::error::NewtError::Config(
"MCP server name cannot be empty".to_string(),
));
}
if !entry.is_valid() {
let need = match entry.transport {
TransportKind::Stdio => "a `command`",
TransportKind::Sse | TransportKind::Http => "a `url`",
};
return Err(crate::error::NewtError::Config(format!(
"a {} MCP server requires {need}",
entry.transport.as_str()
)));
}
Ok(())
}
pub(crate) fn entry_to_toml_table(
entry: &McpServerEntry,
description: Option<&str>,
) -> crate::error::Result<toml_edit::Table> {
let mut table = toml_edit::Table::new();
table["name"] = toml_edit::value(&entry.name);
if let Some(description) = description {
table["description"] = toml_edit::value(description);
}
if !entry.enabled {
table["enabled"] = toml_edit::value(false);
}
if entry.transport != TransportKind::Stdio {
table["type"] = toml_edit::value(entry.transport.as_str());
}
if let Some(command) = &entry.command {
table["command"] = toml_edit::value(command);
}
if !entry.args.is_empty() {
table["args"] = toml_edit::value(toml_edit::Array::from_iter(&entry.args));
}
if !entry.login_argv.is_empty() {
table["login_argv"] = toml_edit::value(toml_edit::Array::from_iter(&entry.login_argv));
}
if !entry.env.is_empty() {
table["env"] = toml_edit::value(secret_map_to_inline_table(&entry.env));
}
if let Some(url) = &entry.url {
table["url"] = toml_edit::value(url);
}
if !entry.headers.is_empty() {
table["headers"] = toml_edit::value(secret_map_to_inline_table(&entry.headers));
}
if let Some(secs) = entry.request_timeout_secs {
table["request_timeout_secs"] = toml_edit::value(i64::try_from(secs).map_err(|_| {
crate::error::NewtError::Config(format!("request timeout {secs}s is out of range"))
})?);
}
Ok(table)
}
fn secret_value_to_toml(value: &SecretValue) -> toml_edit::Value {
match value {
SecretValue::Literal(s) => toml_edit::Value::from(s.as_str()),
SecretValue::Ref(r) => {
let mut table = toml_edit::InlineTable::new();
if let Some(env) = &r.env {
table.insert("env", env.as_str().into());
}
if let Some(file) = &r.file {
table.insert("file", file.as_str().into());
}
if let Some(cmd) = &r.cmd {
table.insert("cmd", cmd.as_str().into());
}
toml_edit::Value::InlineTable(table)
}
}
}
fn secret_map_to_inline_table(map: &BTreeMap<String, SecretValue>) -> toml_edit::InlineTable {
let mut table = toml_edit::InlineTable::new();
for (k, v) in map {
table.insert(k, secret_value_to_toml(v));
}
table
}
pub fn parse_claude_mcp(value: &serde_json::Value) -> Vec<McpServerEntry> {
let Some(map) = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
else {
return Vec::new();
};
map.iter()
.filter_map(|(name, entry)| {
let mut parsed: McpServerEntry = serde_json::from_value(entry.clone()).ok()?;
parsed.name = name.clone();
parsed.trust = McpTrust::Untrusted;
parsed.login_argv.clear();
Some(parsed)
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpImportIssue {
UnknownField,
InvalidShape,
UnsupportedSemantics,
}
impl std::fmt::Display for McpImportIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::UnknownField => "unsupported fields",
Self::InvalidShape => "an invalid entry shape",
Self::UnsupportedSemantics => "unsupported or ambiguous transport semantics",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpImportRejection {
pub name: Option<String>,
pub issue: McpImportIssue,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct McpImportParseReport {
pub entries: Vec<McpServerEntry>,
pub rejected: Vec<McpImportRejection>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct McpImportSyntaxError;
impl std::fmt::Display for McpImportSyntaxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid configuration syntax")
}
}
impl std::error::Error for McpImportSyntaxError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpHttpUrlIssue {
Invalid,
UnsupportedScheme,
UserInfo,
Query,
Fragment,
MissingHost,
}
impl std::fmt::Display for McpHttpUrlIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Invalid => "an invalid URL",
Self::UnsupportedScheme => "an unsupported URL scheme",
Self::UserInfo => "URL userinfo",
Self::Query => "a URL query",
Self::Fragment => "a URL fragment",
Self::MissingHost => "a URL without a hostname",
})
}
}
impl std::error::Error for McpHttpUrlIssue {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CanonicalMcpHttpUrl {
pub url: String,
pub host: String,
}
pub fn canonical_mcp_http_url(
raw: &str,
) -> std::result::Result<CanonicalMcpHttpUrl, McpHttpUrlIssue> {
let parsed = reqwest::Url::parse(raw).map_err(|_| McpHttpUrlIssue::Invalid)?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(McpHttpUrlIssue::UnsupportedScheme);
}
let raw_authority_has_userinfo = raw
.split_once("://")
.and_then(|(_, rest)| rest.split(['/', '?', '#']).next())
.is_some_and(|authority| authority.contains('@'));
if raw_authority_has_userinfo || !parsed.username().is_empty() || parsed.password().is_some() {
return Err(McpHttpUrlIssue::UserInfo);
}
if parsed.query().is_some() {
return Err(McpHttpUrlIssue::Query);
}
if parsed.fragment().is_some() {
return Err(McpHttpUrlIssue::Fragment);
}
let host = parsed
.host_str()
.filter(|host| !host.is_empty())
.ok_or(McpHttpUrlIssue::MissingHost)?
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
Ok(CanonicalMcpHttpUrl {
url: parsed.to_string(),
host,
})
}
fn portable_env_names<T>(values: &BTreeMap<String, T>) -> bool {
let mut canonical = std::collections::BTreeSet::new();
values
.keys()
.all(|name| is_safe_env_var_name(name) && canonical.insert(name.to_ascii_uppercase()))
}
fn portable_header_names<T>(values: &BTreeMap<String, T>) -> bool {
let mut canonical = std::collections::BTreeSet::new();
values.keys().all(|name| {
is_safe_http_header_name(name)
&& !is_transport_owned_mcp_header(name)
&& canonical.insert(name.to_ascii_lowercase())
})
}
#[must_use]
pub fn is_transport_owned_mcp_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"host" | "mcp-protocol-version" | "mcp-session-id"
)
}
#[must_use]
pub fn parse_claude_mcp_for_import(value: &serde_json::Value) -> McpImportParseReport {
const SUPPORTED_FIELDS: &[&str] = &[
"enabled",
"type",
"command",
"args",
"env",
"url",
"headers",
"request_timeout_secs",
"requestTimeoutSecs",
];
let Some(servers) = value.get("mcpServers") else {
return McpImportParseReport::default();
};
let Some(servers) = servers.as_object() else {
return McpImportParseReport {
entries: Vec::new(),
rejected: vec![McpImportRejection {
name: None,
issue: McpImportIssue::InvalidShape,
}],
};
};
let mut report = McpImportParseReport::default();
for (name, raw) in servers {
let Some(object) = raw.as_object() else {
report.rejected.push(McpImportRejection {
name: Some(name.clone()),
issue: McpImportIssue::InvalidShape,
});
continue;
};
if object
.keys()
.any(|field| !SUPPORTED_FIELDS.contains(&field.as_str()))
{
report.rejected.push(McpImportRejection {
name: Some(name.clone()),
issue: McpImportIssue::UnknownField,
});
continue;
}
let Ok(mut entry) = serde_json::from_value::<McpServerEntry>(raw.clone()) else {
report.rejected.push(McpImportRejection {
name: Some(name.clone()),
issue: McpImportIssue::InvalidShape,
});
continue;
};
entry.name = name.clone();
entry.trust = McpTrust::Untrusted;
let semantics_preserved = match entry.transport {
TransportKind::Stdio => {
entry.command.is_some()
&& entry.url.is_none()
&& entry.headers.is_empty()
&& portable_env_names(&entry.env)
}
TransportKind::Http => {
entry.url.is_some()
&& entry.command.is_none()
&& entry.args.is_empty()
&& entry.env.is_empty()
&& portable_header_names(&entry.headers)
}
TransportKind::Sse => false,
};
if semantics_preserved {
report.entries.push(entry);
} else {
report.rejected.push(McpImportRejection {
name: Some(name.clone()),
issue: McpImportIssue::UnsupportedSemantics,
});
}
}
report
}
#[must_use]
pub fn parse_codex_mcp_toml(text: &str) -> Vec<McpServerEntry> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SourcedCodexEnvVar {
name: String,
#[serde(default)]
source: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum CodexEnvVar {
Name(String),
Sourced(SourcedCodexEnvVar),
}
impl CodexEnvVar {
fn local_name(self) -> Option<String> {
match self {
Self::Name(name) => Some(name),
Self::Sourced(SourcedCodexEnvVar { name, source }) => match source.as_deref() {
None | Some("local") => Some(name),
_ => None,
},
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CodexEntry {
url: Option<String>,
command: Option<String>,
args: Option<Vec<String>>,
env: Option<BTreeMap<String, String>>,
bearer_token_env_var: Option<String>,
http_headers: Option<BTreeMap<String, String>>,
env_http_headers: Option<BTreeMap<String, String>>,
env_vars: Option<Vec<CodexEnvVar>>,
auth: Option<String>,
startup_timeout_sec: Option<u64>,
tool_timeout_sec: Option<u64>,
required: Option<bool>,
enabled: Option<bool>,
}
let Ok(document) = toml::from_str::<toml::Value>(text) else {
return Vec::new();
};
let Some(servers) = document.get("mcp_servers").and_then(toml::Value::as_table) else {
return Vec::new();
};
servers
.iter()
.filter_map(|(name, value)| {
if name.trim().is_empty() {
return None;
}
let parsed: CodexEntry = value.clone().try_into().ok()?;
let CodexEntry {
url,
command,
args,
env,
bearer_token_env_var,
http_headers,
env_http_headers,
env_vars,
auth,
startup_timeout_sec,
tool_timeout_sec,
required,
enabled,
} = parsed;
if auth.as_deref().is_some_and(|kind| kind != "oauth")
|| required.unwrap_or(false)
|| startup_timeout_sec.is_some()
{
return None;
}
match (url, command) {
(Some(url), None) => {
if args.is_some()
|| env.is_some()
|| env_vars.is_some()
|| url.trim().is_empty()
{
return None;
}
if http_headers
.as_ref()
.is_some_and(|values| !portable_header_names(values))
{
return None;
}
let mut headers = BTreeMap::new();
let mut canonical_header_names = std::collections::BTreeSet::new();
for (header, env_var) in env_http_headers.unwrap_or_default() {
if !is_safe_http_header_name(&header)
|| is_transport_owned_mcp_header(&header)
|| !is_safe_env_var_name(&env_var)
|| !canonical_header_names.insert(header.to_ascii_lowercase())
{
return None;
}
headers.insert(
header,
SecretValue::Ref(SecretRef {
env: Some(env_var),
..Default::default()
}),
);
}
if let Some(env_var) = bearer_token_env_var {
if !is_safe_env_var_name(&env_var)
|| !canonical_header_names.insert("authorization".to_string())
{
return None;
}
headers.insert(
"Authorization".to_string(),
SecretValue::literal(format!("Bearer ${{env:{env_var}}}")),
);
}
Some(McpServerEntry {
name: name.clone(),
enabled: enabled.unwrap_or(true),
transport: TransportKind::Http,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: Some(url),
headers,
login_argv: Vec::new(),
request_timeout_secs: tool_timeout_sec,
trust: McpTrust::Untrusted,
origin: None,
})
}
(None, Some(command)) => {
if bearer_token_env_var.is_some()
|| http_headers.is_some()
|| env_http_headers.is_some()
|| auth.is_some()
|| command.trim().is_empty()
{
return None;
}
if env
.as_ref()
.is_some_and(|values| !portable_env_names(values))
{
return None;
}
let mut forwarded_env = BTreeMap::new();
let mut canonical_env_names = std::collections::BTreeSet::new();
for env_var in env_vars.unwrap_or_default() {
let name = env_var.local_name()?;
if !is_safe_env_var_name(&name)
|| !canonical_env_names.insert(name.to_ascii_uppercase())
{
return None;
}
forwarded_env.insert(
name.clone(),
SecretValue::Ref(SecretRef {
env: Some(name),
..Default::default()
}),
);
}
Some(McpServerEntry {
name: name.clone(),
enabled: enabled.unwrap_or(true),
transport: TransportKind::Stdio,
command: Some(command),
args: args.unwrap_or_default(),
env: forwarded_env,
url: None,
headers: BTreeMap::new(),
login_argv: Vec::new(),
request_timeout_secs: tool_timeout_sec,
trust: McpTrust::Untrusted,
origin: None,
})
}
_ => None,
}
})
.collect()
}
pub fn parse_codex_mcp_toml_for_import(
text: &str,
) -> std::result::Result<McpImportParseReport, McpImportSyntaxError> {
const SUPPORTED_FIELDS: &[&str] = &[
"url",
"command",
"args",
"env",
"bearer_token_env_var",
"http_headers",
"env_http_headers",
"env_vars",
"auth",
"startup_timeout_sec",
"tool_timeout_sec",
"required",
"enabled",
];
let document = toml::from_str::<toml::Value>(text).map_err(|_| McpImportSyntaxError)?;
let Some(raw_servers) = document.get("mcp_servers") else {
return Ok(McpImportParseReport::default());
};
let Some(raw_servers) = raw_servers.as_table() else {
return Ok(McpImportParseReport {
entries: Vec::new(),
rejected: vec![McpImportRejection {
name: None,
issue: McpImportIssue::InvalidShape,
}],
});
};
let entries = parse_codex_mcp_toml(text);
let accepted: std::collections::BTreeSet<&str> =
entries.iter().map(|entry| entry.name.as_str()).collect();
let mut rejected = Vec::new();
for (name, raw) in raw_servers {
if accepted.contains(name.as_str()) {
continue;
}
let issue = match raw.as_table() {
None => McpImportIssue::InvalidShape,
Some(table)
if table
.keys()
.any(|field| !SUPPORTED_FIELDS.contains(&field.as_str())) =>
{
McpImportIssue::UnknownField
}
Some(_) => McpImportIssue::UnsupportedSemantics,
};
rejected.push(McpImportRejection {
name: Some(name.clone()),
issue,
});
}
Ok(McpImportParseReport { entries, rejected })
}
#[must_use]
pub fn codex_mcp_omitted_field_counts(text: &str) -> BTreeMap<String, usize> {
let Ok(document) = toml::from_str::<toml::Value>(text) else {
return BTreeMap::new();
};
let Some(servers) = document.get("mcp_servers").and_then(toml::Value::as_table) else {
return BTreeMap::new();
};
let mut omitted = BTreeMap::new();
for (name, value) in servers {
let Some(table) = value.as_table() else {
continue;
};
let mut count = 0;
for source in ["env", "http_headers"] {
let Some(values) = table.get(source).and_then(toml::Value::as_table) else {
continue;
};
count += values.len();
}
if count > 0 {
omitted.insert(name.clone(), count);
}
}
omitted
}
fn is_safe_env_var_name(name: &str) -> bool {
let mut bytes = name.bytes();
matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_'))
&& bytes.all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_'))
}
fn is_safe_http_header_name(name: &str) -> bool {
!name.is_empty()
&& name.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
fn load_claude_file(path: &Path) -> Vec<McpServerEntry> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
parse_claude_mcp(&value)
}
pub fn parse_newt_mcp_toml(text: &str) -> Vec<McpServerEntry> {
#[derive(Deserialize, Default)]
struct Doc {
#[serde(default)]
mcp_servers: Vec<McpServerEntry>,
}
toml::from_str::<Doc>(text)
.map(|d| d.mcp_servers)
.unwrap_or_default()
}
fn load_newt_mcp_toml(path: &Path) -> Vec<McpServerEntry> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
parse_newt_mcp_toml(&text)
}
#[must_use]
pub fn runtime_server_prefix(name: &str, sanitize: bool) -> String {
if sanitize {
name.replace('-', "_")
} else {
name.to_owned()
}
}
#[must_use]
pub fn runtime_server_prefix_is_unambiguous(name: &str, sanitize: bool) -> bool {
!runtime_server_prefix(name, sanitize).contains("__")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum McpSource {
Configuration,
UserMcpFile,
ClaudeUser,
ClaudeProject,
}
#[derive(Debug, Clone)]
pub struct DiscoveredMcpServer {
pub entry: McpServerEntry,
pub source: McpSource,
}
#[derive(Debug, Clone)]
pub struct McpConflict {
pub name: String,
pub source: McpSource,
pub winner: String,
}
#[derive(Debug, Default)]
pub struct McpDiscoveryReport {
pub servers: Vec<DiscoveredMcpServer>,
pub conflicts: Vec<McpConflict>,
}
fn dedup_report(sources: Vec<DiscoveredMcpServer>, sanitize: bool) -> McpDiscoveryReport {
let mut seen = BTreeMap::new();
let mut report = McpDiscoveryReport::default();
for server in sources {
let entry = &server.entry;
if !entry.is_valid() || !runtime_server_prefix_is_unambiguous(&entry.name, sanitize) {
continue;
}
let prefix = runtime_server_prefix(&entry.name, sanitize);
if let Some(winner) = seen.get(&prefix) {
report.conflicts.push(McpConflict {
name: entry.name.clone(),
source: server.source,
winner: String::clone(winner),
});
} else {
seen.insert(prefix, entry.name.clone());
report.servers.push(server);
}
}
report
}
#[cfg(test)]
fn dedup_valid_first_wins(sources: Vec<McpServerEntry>, sanitize: bool) -> Vec<McpServerEntry> {
dedup_report(
sources
.into_iter()
.map(|entry| DiscoveredMcpServer {
entry,
source: McpSource::Configuration,
})
.collect(),
sanitize,
)
.servers
.into_iter()
.map(|server| server.entry)
.collect()
}
pub fn discover(
newt_servers: &[McpServerEntry],
newt_mcp_toml: Option<&Path>,
home: Option<&Path>,
workspace: &Path,
) -> Vec<McpServerEntry> {
discover_with_namespace_mode(newt_servers, newt_mcp_toml, home, workspace, false)
}
pub fn discover_with_namespace_mode(
newt_servers: &[McpServerEntry],
newt_mcp_toml: Option<&Path>,
home: Option<&Path>,
workspace: &Path,
sanitize_server_names: bool,
) -> Vec<McpServerEntry> {
discover_report_with_namespace_mode(
newt_servers,
newt_mcp_toml,
home,
workspace,
sanitize_server_names,
)
.servers
.into_iter()
.map(|server| server.entry)
.collect()
}
pub fn discover_report_with_namespace_mode(
newt_servers: &[McpServerEntry],
newt_mcp_toml: Option<&Path>,
home: Option<&Path>,
workspace: &Path,
sanitize_server_names: bool,
) -> McpDiscoveryReport {
let mut sources = Vec::new();
let mut append = |entries: Vec<McpServerEntry>, source| {
sources.extend(
entries
.into_iter()
.map(|entry| DiscoveredMcpServer { entry, source }),
);
};
append(newt_servers.to_vec(), McpSource::Configuration);
if let Some(path) = newt_mcp_toml {
append(load_newt_mcp_toml(path), McpSource::UserMcpFile);
}
let stamp = |mut entries: Vec<McpServerEntry>, origin: McpOrigin| {
for entry in &mut entries {
entry.origin = Some(origin.clone());
}
entries
};
if let Some(home) = home {
append(
stamp(
load_claude_file(&home.join(".claude.json")),
McpOrigin::ClaudeUser,
),
McpSource::ClaudeUser,
);
}
let project_file = workspace.join(".mcp.json");
append(
stamp(
load_claude_file(&project_file),
McpOrigin::File(project_file.clone()),
),
McpSource::ClaudeProject,
);
dedup_report(sources, sanitize_server_names)
}
#[cfg(test)]
#[path = "mcp_tests/mod.rs"]
mod tests;