use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use crate::bin_resolve::is_ephemeral_build_path;
use crate::claude_config::{mcp_server_entry, patch_mcp_server};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GuiMcpClient {
ChatGpt,
}
impl GuiMcpClient {
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"chatgpt" | "chat-gpt" | "chat_gpt" | "openai" => Some(Self::ChatGpt),
_ => None,
}
}
#[must_use]
pub const fn supported() -> &'static [&'static str] {
&["chatgpt"]
}
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::ChatGpt => "ChatGPT desktop",
}
}
#[must_use]
pub fn local_config_path(self, _home: &Path) -> Option<PathBuf> {
match self {
Self::ChatGpt => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuiClientEntry {
pub server_key: String,
pub command: PathBuf,
pub args: Vec<String>,
pub working_dir: PathBuf,
}
impl GuiClientEntry {
#[must_use]
pub fn to_json(&self) -> Value {
let args: Vec<&str> = self.args.iter().map(String::as_str).collect();
let mut entry = mcp_server_entry(&self.command.to_string_lossy(), &args);
if let Some(obj) = entry.as_object_mut() {
obj.insert(
"cwd".to_string(),
Value::String(self.working_dir.to_string_lossy().into_owned()),
);
}
entry
}
#[must_use]
pub fn instructions(&self, client: GuiMcpClient) -> String {
let mut out = format!(
"Add an MCP server named `{}` in {} with these exact values:\n\
\n Command: {}\n Arguments: {}\n Working directory: {}\n",
self.server_key,
client.display_name(),
self.command.display(),
self.args.join(" "),
self.working_dir.display(),
);
if is_ephemeral_build_path(&self.command) {
out.push_str(
"\nNote: that path is a build directory, not an installed binary. \
Run `cargo install --path <crate>` and re-run this command so the \
entry survives a `cargo clean`.\n",
);
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum GuiClientOutcome {
PasteByHand {
entry: GuiClientEntry,
},
Wrote {
path: PathBuf,
entry: GuiClientEntry,
changed: bool,
},
}
pub fn running_binary_path() -> Result<PathBuf> {
let exe = std::env::current_exe().context("resolve the running executable path")?;
let exe = exe.canonicalize().unwrap_or(exe);
if !exe.is_absolute() {
bail!(
"the running executable resolved to a relative path ({}); \
a GUI client cannot spawn it",
exe.display()
);
}
Ok(exe)
}
pub fn default_working_dir() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("could not resolve home directory"))?;
if !home.is_dir() {
bail!("home directory {} does not exist", home.display());
}
Ok(home)
}
pub fn build_entry(
server_key: &str,
args: &[&str],
command: &Path,
working_dir: &Path,
) -> Result<GuiClientEntry> {
if !command.is_absolute() {
bail!(
"MCP command `{}` is not an absolute path; a GUI client launched by \
launchd sees only PATH=/usr/bin:/bin:/usr/sbin:/sbin and cannot find it",
command.display()
);
}
if !working_dir.is_dir() {
bail!(
"working directory {} does not exist; the client spawn fails before \
the server starts",
working_dir.display()
);
}
Ok(GuiClientEntry {
server_key: server_key.to_string(),
command: command.to_path_buf(),
args: args.iter().map(|a| (*a).to_string()).collect(),
working_dir: working_dir.to_path_buf(),
})
}
pub fn configure(
client: GuiMcpClient,
server_key: &str,
args: &[&str],
home: &Path,
) -> Result<GuiClientOutcome> {
let command = running_binary_path()?;
let working_dir = default_working_dir()?;
let entry = build_entry(server_key, args, &command, &working_dir)?;
match client.local_config_path(home) {
None => Ok(GuiClientOutcome::PasteByHand { entry }),
Some(path) => {
let changed = patch_mcp_server(&path, server_key, &entry.to_json())
.with_context(|| format!("register {server_key} in {}", path.display()))?;
Ok(GuiClientOutcome::Wrote {
path,
entry,
changed,
})
}
}
}
pub fn report(client_name: &str, server_key: &str, args: &[&str], home: &Path) -> Result<String> {
let client = GuiMcpClient::parse(client_name).ok_or_else(|| {
anyhow!(
"unknown GUI client `{client_name}`; supported: {}",
GuiMcpClient::supported().join(", ")
)
})?;
Ok(match configure(client, server_key, args, home)? {
GuiClientOutcome::PasteByHand { entry } => format!(
"{}\n{} keeps no local MCP config file this command may write, so \
nothing on disk was changed.\n",
entry.instructions(client),
client.display_name(),
),
GuiClientOutcome::Wrote {
path,
entry,
changed,
} => {
let verb = if changed {
"Registered"
} else {
"Already registered"
};
format!(
"{verb} `{}` for {} in {}\n Command: {}\n \
Arguments: {}\n Working directory: {}\n",
entry.server_key,
client.display_name(),
path.display(),
entry.command.display(),
entry.args.join(" "),
entry.working_dir.display(),
)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tempdir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"trusty-gui-mcp-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after the unix epoch")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("create tempdir");
dir
}
#[test]
fn parse_accepts_known_spellings() {
for raw in ["chatgpt", "ChatGPT", " chat-gpt ", "chat_gpt", "OpenAI"] {
assert_eq!(
GuiMcpClient::parse(raw),
Some(GuiMcpClient::ChatGpt),
"should parse {raw}"
);
}
}
#[test]
fn parse_rejects_unknown_client() {
assert_eq!(GuiMcpClient::parse("claude-desktop"), None);
assert_eq!(GuiMcpClient::parse(""), None);
}
#[test]
fn chatgpt_has_no_writable_local_config() {
let home = Path::new("/Users/nobody");
assert_eq!(GuiMcpClient::ChatGpt.local_config_path(home), None);
}
#[test]
fn build_entry_rejects_a_bare_command() {
let dir = tempdir();
let err = build_entry(
"trusty-memory",
&["serve"],
Path::new("trusty-memory"),
&dir,
)
.expect_err("a bare command name must be rejected");
assert!(
err.to_string().contains("not an absolute path"),
"unexpected error: {err}"
);
}
#[test]
fn build_entry_rejects_a_missing_working_dir() {
let dir = tempdir();
let missing = dir.join("code-that-does-not-exist");
let exe = dir.join("trusty-memory");
std::fs::write(&exe, b"").expect("write fake binary");
let err = build_entry("trusty-memory", &["serve"], &exe, &missing)
.expect_err("a missing working directory must be rejected");
assert!(
err.to_string().contains("does not exist"),
"unexpected error: {err}"
);
}
#[test]
fn build_entry_accepts_an_absolute_command() {
let dir = tempdir();
let exe = dir.join("trusty-search");
std::fs::write(&exe, b"").expect("write fake binary");
let entry =
build_entry("trusty-search", &["serve"], &exe, &dir).expect("entry should build");
assert_eq!(entry.command, exe);
assert_eq!(entry.args, vec!["serve".to_string()]);
assert_eq!(entry.working_dir, dir);
}
#[test]
fn entry_json_has_absolute_command() {
let dir = tempdir();
let exe = dir.join("trusty-memory");
std::fs::write(&exe, b"").expect("write fake binary");
let entry =
build_entry("trusty-memory", &["serve"], &exe, &dir).expect("entry should build");
let rendered = entry.to_json();
let command = rendered["command"]
.as_str()
.expect("command is a JSON string");
assert!(
Path::new(command).is_absolute(),
"command must be absolute, got {command}"
);
assert_ne!(
command, "trusty-memory",
"the bare binary name is the pre-fix shape that exits 127 under launchd"
);
let cwd = rendered["cwd"].as_str().expect("cwd is a JSON string");
assert!(
Path::new(cwd).is_dir(),
"cwd must exist at write time, got {cwd}"
);
}
#[test]
fn entry_json_carries_cwd() {
let dir = tempdir();
let exe = dir.join("trusty-memory");
std::fs::write(&exe, b"").expect("write fake binary");
let entry =
build_entry("trusty-memory", &["serve"], &exe, &dir).expect("entry should build");
assert_eq!(
entry.to_json(),
json!({
"command": exe.to_string_lossy(),
"args": ["serve"],
"cwd": dir.to_string_lossy(),
})
);
}
#[test]
fn instructions_name_every_field() {
let dir = tempdir();
let exe = Path::new("/usr/local/bin/trusty-memory");
let entry = build_entry("trusty-memory", &["serve"], exe, &dir).expect("entry builds");
let text = entry.instructions(GuiMcpClient::ChatGpt);
assert!(text.contains("ChatGPT desktop"), "{text}");
assert!(text.contains("/usr/local/bin/trusty-memory"), "{text}");
assert!(text.contains("serve"), "{text}");
assert!(text.contains(&dir.display().to_string()), "{text}");
assert!(!text.contains("build directory"), "{text}");
}
#[test]
fn instructions_warn_about_a_build_directory_binary() {
let dir = tempdir();
let build_dir = dir.join("target").join("debug");
std::fs::create_dir_all(&build_dir).expect("create build dir");
let exe = build_dir.join("trusty-memory");
std::fs::write(&exe, b"").expect("write fake binary");
let entry =
build_entry("trusty-memory", &["serve"], &exe, &dir).expect("entry should build");
let text = entry.instructions(GuiMcpClient::ChatGpt);
assert!(text.contains("build directory"), "{text}");
}
#[test]
fn running_binary_path_is_absolute() {
let exe = running_binary_path().expect("the test binary path resolves");
assert!(exe.is_absolute(), "got {}", exe.display());
}
#[test]
fn default_working_dir_exists() {
let dir = default_working_dir().expect("home directory resolves");
assert!(dir.is_dir(), "got {}", dir.display());
}
#[test]
fn configure_returns_paste_by_hand_for_chatgpt() {
let home = tempdir();
let outcome = configure(GuiMcpClient::ChatGpt, "trusty-memory", &["serve"], &home)
.expect("configure should succeed");
match outcome {
GuiClientOutcome::PasteByHand { entry } => {
assert!(entry.command.is_absolute());
assert!(entry.working_dir.is_dir());
}
other => panic!("expected PasteByHand, got {other:?}"),
}
}
#[test]
fn report_for_chatgpt_names_the_manual_step() {
let home = tempdir();
let text =
report("chatgpt", "trusty-memory", &["serve"], &home).expect("report should render");
assert!(text.contains("ChatGPT desktop"), "{text}");
assert!(text.contains("Working directory:"), "{text}");
assert!(text.contains("nothing on disk was changed"), "{text}");
}
#[test]
fn report_rejects_an_unknown_client() {
let home = tempdir();
let err = report("cursor", "trusty-memory", &["serve"], &home)
.expect_err("an unknown client must be rejected");
assert!(err.to_string().contains("chatgpt"), "{err}");
}
#[test]
fn configure_writes_and_preserves_other_servers() {
let dir = tempdir();
let path = dir.join("config.json");
std::fs::write(
&path,
br#"{"mcpServers":{"other":{"command":"/bin/true"}}}"#,
)
.expect("seed config");
let exe = dir.join("trusty-search");
std::fs::write(&exe, b"").expect("write fake binary");
let entry =
build_entry("trusty-search", &["serve"], &exe, &dir).expect("entry should build");
let changed = patch_mcp_server(&path, "trusty-search", &entry.to_json())
.expect("upsert should succeed");
assert!(changed, "first write changes the file");
let raw = std::fs::read_to_string(&path).expect("read back");
let value: Value = serde_json::from_str(&raw).expect("parse back");
assert_eq!(value["mcpServers"]["other"]["command"], "/bin/true");
assert_eq!(
value["mcpServers"]["trusty-search"]["command"],
exe.to_string_lossy().as_ref()
);
let again = patch_mcp_server(&path, "trusty-search", &entry.to_json())
.expect("second upsert should succeed");
assert!(!again, "an identical entry must not rewrite the file");
}
}