use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::CheckResult;
const MCP_ENTRYPOINT_ARG: &str = "serve";
const LEGACY_TRANSPORT_FLAG: &str = "--stdio";
const TRANSPORT_CONTRACT: &str =
"MCP over stdio; the process stays attached to stdin/stdout and starts the \
daemon itself when one is not already running";
#[derive(Debug, Clone, PartialEq)]
pub(super) struct McpRegistration {
pub(super) path: PathBuf,
pub(super) command: String,
pub(super) args: Vec<String>,
pub(super) env: BTreeMap<String, String>,
}
impl McpRegistration {
fn launches_mcp(&self) -> bool {
self.args.iter().any(|a| a == MCP_ENTRYPOINT_ARG)
}
fn carries_legacy_flag(&self) -> bool {
self.args.iter().any(|a| a == LEGACY_TRANSPORT_FLAG)
}
fn binding(&self) -> String {
let palace = self
.args
.iter()
.enumerate()
.find_map(|(i, a)| flag_value(a, "--palace", self.args.get(i + 1)))
.map_or_else(
|| "palace resolved from the client's working directory".to_string(),
|p| format!("palace '{p}'"),
);
match self.env.get("TRUSTY_DATA_DIR_OVERRIDE") {
Some(dir) => format!("{palace}, isolated data dir {dir}"),
None => format!("{palace}, machine-wide data dir"),
}
}
}
fn flag_value(arg: &str, flag: &str, next: Option<&String>) -> Option<String> {
let rest = arg.strip_prefix(flag)?;
if let Some(v) = rest.strip_prefix('=') {
return (!v.is_empty()).then(|| v.to_string());
}
if rest.is_empty() {
return next.filter(|v| !v.starts_with('-')).cloned();
}
None
}
pub(super) fn check_registration(
reg: Option<&McpRegistration>,
client: &str,
path: &Path,
version: &str,
) -> CheckResult {
let label = format!("{client} MCP registration");
let Some(reg) = reg else {
return CheckResult::warn(
label,
format!(
"absent from {} — run `trusty-memory setup` to register it",
path.display()
),
);
};
let detail = format!(
"{} — exe={} ({version}), args={:?}, transport={TRANSPORT_CONTRACT}, binding={}",
reg.path.display(),
reg.command,
reg.args,
reg.binding(),
);
if !reg.launches_mcp() {
return CheckResult::fail(
label,
format!(
"{detail}. The vector never reaches `{MCP_ENTRYPOINT_ARG}`, so the client \
launches a process that exits before MCP initialization while showing the \
connection as enabled (#5265). Repair it with `trusty-memory setup`."
),
);
}
if reg.carries_legacy_flag() {
return CheckResult::warn(
label,
format!(
"{detail}. `{LEGACY_TRANSPORT_FLAG}` is the retired transport-selection flag \
(#5265) — bare `{MCP_ENTRYPOINT_ARG}` is the contract. Run `trusty-memory \
setup` to rewrite it."
),
);
}
CheckResult::pass(label, detail)
}
fn registered_exe_version(command: &str) -> String {
match std::process::Command::new(command)
.arg("--version")
.output()
{
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if text.is_empty() {
"version unreadable".to_string()
} else {
text
}
}
Ok(out) => format!("`{command} --version` exited {}", out.status),
Err(e) => format!("NOT LAUNCHABLE: {e}"),
}
}
pub(super) fn read_codex_registration(home: &Path, server_key: &str) -> Option<McpRegistration> {
let path = trusty_common::codex_config::codex_config_path(home);
let doc: toml::Value = std::fs::read_to_string(&path).ok()?.parse().ok()?;
let entry = doc.get("mcp_servers")?.get(server_key)?;
Some(McpRegistration {
path,
command: entry.get("command")?.as_str()?.to_string(),
args: entry
.get("args")
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
env: entry
.get("env")
.and_then(toml::Value::as_table)
.map(|t| {
t.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default(),
})
}
pub(super) fn read_claude_registration(path: &Path, server_key: &str) -> Option<McpRegistration> {
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
let entry = doc.get("mcpServers")?.get(server_key)?;
Some(McpRegistration {
path: path.to_path_buf(),
command: entry.get("command")?.as_str()?.to_string(),
args: entry
.get("args")
.and_then(serde_json::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
env: entry
.get("env")
.and_then(serde_json::Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default(),
})
}
fn claude_global_settings_paths(home: &Path) -> Vec<PathBuf> {
["settings.json", "settings.local.json"]
.iter()
.map(|n| home.join(".claude").join(n))
.collect()
}
pub(super) fn check_claude_registrations(
home: &Path,
server_key: &str,
version_of: &dyn Fn(Option<&McpRegistration>) -> String,
) -> Vec<CheckResult> {
let paths = claude_global_settings_paths(home);
let existing: Vec<&PathBuf> = paths.iter().filter(|p| p.is_file()).collect();
if existing.is_empty() {
return vec![CheckResult::warn(
"Claude Code MCP registration",
format!(
"neither global settings file exists ({}) — nothing was checked for this \
client. `trusty-memory setup` also patches PROJECT-LOCAL \
.claude/settings.json files, which doctor does not scan; check those by \
hand, or run `trusty-memory setup` to (re)register everywhere.",
paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
)];
}
existing
.into_iter()
.map(|path| {
let reg = read_claude_registration(path, server_key);
check_registration(reg.as_ref(), "Claude Code", path, &version_of(reg.as_ref()))
})
.collect()
}
pub(super) fn check_mcp_registrations(server_key: &str) -> Vec<CheckResult> {
let Some(home) = dirs::home_dir() else {
return vec![CheckResult::unknown(
"MCP registrations",
"could not resolve the home directory, so no client config was read",
)];
};
let version_of = |reg: Option<&McpRegistration>| {
reg.map_or_else(
|| "no executable".to_string(),
|r| registered_exe_version(&r.command),
)
};
let codex = read_codex_registration(&home, server_key);
let mut results = vec![check_registration(
codex.as_ref(),
"Codex",
&trusty_common::codex_config::codex_config_path(&home),
&version_of(codex.as_ref()),
)];
results.extend(check_claude_registrations(&home, server_key, &version_of));
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::doctor::CheckStatus;
fn reg(args: &[&str]) -> McpRegistration {
McpRegistration {
path: PathBuf::from("/x/.codex/config.toml"),
command: "trusty-memory".into(),
args: args.iter().map(|s| (*s).to_string()).collect(),
env: BTreeMap::new(),
}
}
fn verdict(args: &[&str]) -> CheckResult {
check_registration(
Some(®(args)),
"Codex",
Path::new("/x/.codex/config.toml"),
"trusty-memory 0.40.0",
)
}
#[test]
fn registration_without_serve_fails() {
let result = verdict(&[]);
assert_eq!(result.status, CheckStatus::Fail, "got {result:?}");
let text = format!("{result:?}");
assert!(text.contains("#5265"), "must cite the issue: {text}");
assert!(
text.contains("trusty-memory setup"),
"must name the repair: {text}"
);
}
#[test]
fn nested_json_string_vector_fails() {
assert_eq!(verdict(&["[\"serve\"]"]).status, CheckStatus::Fail);
}
#[test]
fn joined_string_vector_fails() {
assert_eq!(verdict(&["serve --stdio"]).status, CheckStatus::Fail);
}
#[test]
fn legacy_stdio_vector_warns() {
let result = verdict(&["serve", "--stdio"]);
assert_eq!(result.status, CheckStatus::Warn, "got {result:?}");
let text = format!("{result:?}");
assert!(text.contains("--stdio"), "must name the flag: {text}");
assert!(
text.contains("trusty-memory setup"),
"must name the repair: {text}"
);
}
#[test]
fn canonical_vector_passes_and_reports_the_four_facts() {
let mut r = reg(&["serve", "--palace", "my-proj"]);
r.env
.insert("TRUSTY_DATA_DIR_OVERRIDE".into(), "/tmp/iso".into());
let result = check_registration(
Some(&r),
"Codex",
Path::new("/x/.codex/config.toml"),
"trusty-memory 0.40.0",
);
assert_eq!(result.status, CheckStatus::Pass, "got {result:?}");
let text = format!("{result:?}");
assert!(text.contains("trusty-memory 0.40.0"), "version: {text}");
assert!(text.contains("\\\"serve\\\""), "effective args: {text}");
assert!(text.contains("MCP over stdio"), "transport: {text}");
assert!(text.contains("palace 'my-proj'"), "binding: {text}");
assert!(text.contains("/tmp/iso"), "data dir: {text}");
}
#[test]
fn unpinned_registration_reports_the_working_directory_rule() {
let binding = reg(&["serve"]).binding();
assert!(binding.contains("working directory"), "got {binding}");
assert!(binding.contains("machine-wide"), "got {binding}");
}
#[test]
fn equals_form_of_the_palace_flag_is_recognised() {
assert!(reg(&["serve", "--palace=x"])
.binding()
.contains("palace 'x'"));
}
#[test]
fn absent_registration_warns() {
let result = check_registration(None, "Codex", Path::new("/x/.codex/config.toml"), "v");
assert_eq!(result.status, CheckStatus::Warn, "got {result:?}");
}
#[test]
fn reads_a_codex_registration() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = trusty_common::codex_config::codex_config_path(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"[mcp_servers.trusty-memory]\ncommand = \"trusty-memory\"\n\
args = [\"serve\"]\n\n[mcp_servers.trusty-memory.env]\n\
TRUSTY_DATA_DIR_OVERRIDE = \"/tmp/iso\"\n",
)
.unwrap();
let r = read_codex_registration(tmp.path(), "trusty-memory").expect("registration");
assert_eq!(r.command, "trusty-memory");
assert_eq!(r.args, vec!["serve".to_string()]);
assert_eq!(
r.env.get("TRUSTY_DATA_DIR_OVERRIDE").map(String::as_str),
Some("/tmp/iso")
);
}
#[test]
fn codex_missing_file_is_none() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(read_codex_registration(tmp.path(), "trusty-memory").is_none());
}
#[test]
fn absent_global_claude_files_still_emit_a_scope_warning() {
let tmp = tempfile::tempdir().expect("tempdir");
let version = |_: Option<&McpRegistration>| "v".to_string();
let results = check_claude_registrations(tmp.path(), "trusty-memory", &version);
assert_eq!(results.len(), 1, "silence is the defect: {results:?}");
assert_eq!(results[0].status, CheckStatus::Warn, "got {:?}", results[0]);
let text = format!("{:?}", results[0]);
assert!(
text.contains("PROJECT-LOCAL"),
"must name what was NOT scanned: {text}"
);
}
#[test]
fn existing_global_claude_file_is_checked() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join(".claude");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("settings.json"),
r#"{"mcpServers":{"trusty-memory":{"command":"trusty-memory","args":[]}}}"#,
)
.unwrap();
let version = |_: Option<&McpRegistration>| "v".to_string();
let results = check_claude_registrations(tmp.path(), "trusty-memory", &version);
assert_eq!(results.len(), 1, "one verdict per existing file");
assert_eq!(
results[0].status,
CheckStatus::Fail,
"an empty vector is the #5265 defect: {:?}",
results[0]
);
}
#[test]
fn reads_a_claude_registration() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
std::fs::write(
&path,
r#"{"mcpServers":{"trusty-memory":{"command":"trusty-memory","args":["serve"]}}}"#,
)
.unwrap();
let r = read_claude_registration(&path, "trusty-memory").expect("registration");
assert_eq!(r.args, vec!["serve".to_string()]);
}
}