use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use crate::oauth;
const HTTP_BIND: &str = "127.0.0.1:7892";
const HTTP_PORT: u16 = 7892;
const PLIST_LABEL: &str = "com.things-mcp.http";
const LOG_DIR_REL: &str = "Library/Logs/things-mcp";
pub async fn run_setup() -> anyhow::Result<()> {
if !cfg!(target_os = "macos") {
anyhow::bail!(
"`things-mcp setup` is macOS-only (uses launchd). \
Configure THINGS_MCP_HTTP + THINGS_MCP_OAUTH_ISSUER under your own \
init system and let the server bootstrap oauth.toml on first start."
);
}
println!("things-mcp setup\n================\n");
let hostname = match detect_tailscale_dns_name() {
Ok(name) => name,
Err(e) => {
eprintln!("Could not detect Tailscale Funnel hostname: {e}\n");
eprintln!("To use this setup helper you need:");
eprintln!(" 1. Tailscale installed and signed in:");
eprintln!(" https://tailscale.com/download/macos");
eprintln!(" 2. Funnel enabled on your tailnet (admin action, once):");
eprintln!(" https://login.tailscale.com/admin/settings/features");
eprintln!(" 3. Then re-run `things-mcp setup`.");
anyhow::bail!("Tailscale not available");
}
};
let issuer = format!("https://{hostname}");
println!("Detected Tailscale Funnel hostname: {hostname}");
println!("OAuth issuer URL will be: {issuer}\n");
if !confirm("Use this hostname? [Y/n] ")? {
anyhow::bail!("aborted by user");
}
let binary_path = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))?;
let plist_path = launchd_plist_path()?;
let log_dir = home_dir()?.join(LOG_DIR_REL);
std::fs::create_dir_all(&log_dir)
.map_err(|e| anyhow::anyhow!("mkdir {}: {e}", log_dir.display()))?;
let plist_body = render_plist(&binary_path, &issuer, &log_dir);
println!("Writing launchd plist → {}", plist_path.display());
if let Some(parent) = plist_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&plist_path, plist_body)?;
println!("Reloading launchd job…");
reload_launchd(&plist_path)?;
println!("Enabling Tailscale Funnel on port {HTTP_PORT}…");
enable_tailscale_funnel(HTTP_PORT)?;
println!("Waiting for OAuth credentials to materialize…");
let oauth_path = oauth::config_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve OAuth config path"))?;
wait_for_file(&oauth_path, Duration::from_secs(10))?;
let creds = read_oauth(&oauth_path)?;
print_credentials_block(&creds, &issuer);
Ok(())
}
pub async fn run_status() -> anyhow::Result<()> {
let mut all_ok = true;
println!("things-mcp status\n=================\n");
match launchd_job_loaded(PLIST_LABEL) {
Ok(true) => println!(" [OK] launchd job {PLIST_LABEL} loaded"),
Ok(false) => {
println!(" [FAIL] launchd job {PLIST_LABEL} not loaded");
println!(" fix: run `things-mcp setup`");
all_ok = false;
}
Err(e) => {
println!(" [FAIL] launchd check errored: {e}");
all_ok = false;
}
}
if tcp_port_listening("127.0.0.1", HTTP_PORT) {
println!(" [OK] HTTP server listening on {HTTP_BIND}");
} else {
println!(" [FAIL] no listener on {HTTP_BIND}");
println!(" fix: check ~/Library/Logs/things-mcp/http.err.log");
all_ok = false;
}
match tailscale_funnel_active(HTTP_PORT) {
Ok(true) => println!(" [OK] Tailscale Funnel is publishing port {HTTP_PORT}"),
Ok(false) => {
println!(" [FAIL] Tailscale Funnel is NOT publishing port {HTTP_PORT}");
println!(" fix: `tailscale funnel --bg {HTTP_PORT}`");
all_ok = false;
}
Err(e) => {
println!(" [WARN] could not check Tailscale Funnel: {e}");
}
}
if std::path::Path::new("/Applications/Things3.app").exists() {
println!(" [OK] Things 3 app present at /Applications/Things3.app");
} else {
println!(" [FAIL] Things 3 not installed at /Applications/Things3.app");
println!(" fix: install from https://culturedcode.com/things/");
all_ok = false;
}
match crate::core::config::resolve_db_path(
&mut crate::core::config::Config::default(),
None,
&home_dir()?,
) {
Ok((db_path, _)) => match crate::core::reader::schema::probe(&db_path) {
Ok(()) => println!(" [OK] Things 3 database readable: {}", db_path.display()),
Err(e) => {
println!(" [FAIL] Things 3 database schema probe failed: {e}");
all_ok = false;
}
},
Err(e) => {
println!(" [FAIL] Could not resolve Things 3 database path: {e}");
all_ok = false;
}
}
let oauth_path = oauth::config_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve OAuth config path"))?;
if oauth_path.exists() {
println!(" [OK] OAuth config present: {}", oauth_path.display());
} else {
println!(" [FAIL] OAuth config missing: {}", oauth_path.display());
println!(" fix: run `things-mcp setup`");
all_ok = false;
}
println!();
if all_ok {
println!("All green.");
Ok(())
} else {
anyhow::bail!("one or more checks failed")
}
}
pub fn run_show_credentials() -> anyhow::Result<()> {
let oauth_path = oauth::config_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve OAuth config path"))?;
if !oauth_path.exists() {
anyhow::bail!(
"OAuth config not found at {}. Run `things-mcp setup` first.",
oauth_path.display()
);
}
let creds = read_oauth(&oauth_path)?;
print_credentials_block(&creds, &creds.issuer);
Ok(())
}
fn detect_tailscale_dns_name() -> anyhow::Result<String> {
let output = Command::new("tailscale")
.args(["status", "--json"])
.output()
.map_err(|e| anyhow::anyhow!("running `tailscale status --json`: {e}"))?;
if !output.status.success() {
anyhow::bail!(
"`tailscale status --json` exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
parse_tailscale_dns_name(&output.stdout)
}
fn parse_tailscale_dns_name(stdout: &[u8]) -> anyhow::Result<String> {
#[derive(serde::Deserialize)]
struct Status {
#[serde(rename = "Self")]
self_: SelfNode,
}
#[derive(serde::Deserialize)]
struct SelfNode {
#[serde(rename = "DNSName")]
dns_name: String,
}
let parsed: Status = serde_json::from_slice(stdout)
.map_err(|e| anyhow::anyhow!("parse Tailscale status JSON: {e}"))?;
let name = parsed.self_.dns_name.trim_end_matches('.').to_string();
if name.is_empty() {
anyhow::bail!("Tailscale Self.DNSName is empty");
}
Ok(name)
}
fn launchd_plist_path() -> anyhow::Result<PathBuf> {
Ok(home_dir()?
.join("Library/LaunchAgents")
.join(format!("{PLIST_LABEL}.plist")))
}
fn home_dir() -> anyhow::Result<PathBuf> {
directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
}
fn render_plist(binary: &Path, issuer: &str, log_dir: &Path) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>exec {binary}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>THINGS_MCP_HTTP</key>
<string>{bind}</string>
<key>THINGS_MCP_OAUTH_ISSUER</key>
<string>{issuer}</string>
<key>RUST_LOG</key>
<string>info,tower_http=debug</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>{out_log}</string>
<key>StandardErrorPath</key>
<string>{err_log}</string>
</dict>
</plist>
"#,
label = PLIST_LABEL,
binary = binary.display(),
bind = HTTP_BIND,
issuer = issuer,
out_log = log_dir.join("http.out.log").display(),
err_log = log_dir.join("http.err.log").display(),
)
}
fn reload_launchd(plist_path: &Path) -> anyhow::Result<()> {
let uid = unsafe { libc_geteuid() };
let domain = format!("gui/{uid}");
let _ = Command::new("launchctl")
.args(["bootout", &domain, &plist_path.display().to_string()])
.output();
let out = Command::new("launchctl")
.args(["bootstrap", &domain, &plist_path.display().to_string()])
.output()
.map_err(|e| anyhow::anyhow!("running launchctl bootstrap: {e}"))?;
if !out.status.success() {
anyhow::bail!(
"launchctl bootstrap failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn enable_tailscale_funnel(port: u16) -> anyhow::Result<()> {
let out = Command::new("tailscale")
.args(["funnel", "--bg", &port.to_string()])
.output()
.map_err(|e| anyhow::anyhow!("running `tailscale funnel`: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("already") {
return Ok(());
}
anyhow::bail!("tailscale funnel exited {}: {}", out.status, stderr.trim());
}
Ok(())
}
fn wait_for_file(path: &Path, timeout: Duration) -> anyhow::Result<()> {
let start = Instant::now();
while start.elapsed() < timeout {
if path.exists() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(200));
}
anyhow::bail!(
"timed out after {:?} waiting for {} — check ~/Library/Logs/things-mcp/http.err.log",
timeout,
path.display()
)
}
fn read_oauth(path: &Path) -> anyhow::Result<oauth::OAuthConfig> {
let bytes = std::fs::read(path).map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
let cfg: oauth::OAuthConfig = toml::from_str(std::str::from_utf8(&bytes)?)?;
Ok(cfg)
}
fn print_credentials_block(creds: &oauth::OAuthConfig, issuer: &str) {
let url = format!("{issuer}/mcp");
println!("\n=== Paste these into Claude.ai → Settings → Connectors → Add custom ===\n");
println!(" Server URL {url}");
println!(" Advanced ▸ Client ID {}", creds.client_id);
println!(" Advanced ▸ Client Secret {}", creds.client_secret);
println!();
}
fn launchd_job_loaded(label: &str) -> anyhow::Result<bool> {
let uid = unsafe { libc_geteuid() };
let out = Command::new("launchctl")
.args(["print", &format!("gui/{uid}/{label}")])
.output()
.map_err(|e| anyhow::anyhow!("running launchctl print: {e}"))?;
Ok(out.status.success())
}
fn tcp_port_listening(host: &str, port: u16) -> bool {
std::net::TcpStream::connect_timeout(
&format!("{host}:{port}")
.parse()
.expect("hardcoded host:port"),
Duration::from_millis(500),
)
.is_ok()
}
fn tailscale_funnel_active(port: u16) -> anyhow::Result<bool> {
let out = Command::new("tailscale")
.args(["funnel", "status"])
.output()
.map_err(|e| anyhow::anyhow!("running `tailscale funnel status`: {e}"))?;
if !out.status.success() {
anyhow::bail!("`tailscale funnel status` exited {}", out.status);
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(stdout.contains(&port.to_string()))
}
fn confirm(prompt: &str) -> anyhow::Result<bool> {
print!("{prompt}");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let trimmed = line.trim();
Ok(trimmed.is_empty() || matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes"))
}
extern "C" {
fn geteuid() -> u32;
}
unsafe fn libc_geteuid() -> u32 {
geteuid()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_dns_name_with_trailing_dot() {
let json = br#"{
"Self": { "DNSName": "laptop.stoat-minnow.ts.net.", "HostName": "Whatever" },
"MagicDNSSuffix": "stoat-minnow.ts.net"
}"#;
let name = parse_tailscale_dns_name(json).unwrap();
assert_eq!(name, "laptop.stoat-minnow.ts.net");
}
#[test]
fn parses_dns_name_without_trailing_dot() {
let json = br#"{ "Self": { "DNSName": "machine.tail-net.ts.net", "HostName": "x" } }"#;
assert_eq!(
parse_tailscale_dns_name(json).unwrap(),
"machine.tail-net.ts.net"
);
}
#[test]
fn rejects_empty_dns_name() {
let json = br#"{ "Self": { "DNSName": "", "HostName": "x" } }"#;
assert!(parse_tailscale_dns_name(json).is_err());
}
#[test]
fn plist_template_substitutes_paths_and_issuer() {
let plist = render_plist(
Path::new("/opt/bin/things-mcp"),
"https://example.test",
Path::new("/var/log/things-mcp"),
);
assert!(plist.contains("<string>exec /opt/bin/things-mcp</string>"));
assert!(plist.contains("<string>https://example.test</string>"));
assert!(plist.contains("<string>/var/log/things-mcp/http.out.log</string>"));
assert!(plist.contains("<string>/var/log/things-mcp/http.err.log</string>"));
assert!(plist.contains("<string>com.things-mcp.http</string>"));
}
}