1use std::io::{self, BufRead, Write};
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::time::{Duration, Instant};
16
17use crate::oauth;
18
19const HTTP_BIND: &str = "127.0.0.1:7892";
20const HTTP_PORT: u16 = 7892;
21const PLIST_LABEL: &str = "com.things-mcp.http";
22const LOG_DIR_REL: &str = "Library/Logs/things-mcp";
23
24pub async fn run_setup() -> anyhow::Result<()> {
27 if !cfg!(target_os = "macos") {
28 anyhow::bail!(
29 "`things-mcp setup` is macOS-only (uses launchd). \
30 Configure THINGS_MCP_HTTP + THINGS_MCP_OAUTH_ISSUER under your own \
31 init system and let the server bootstrap oauth.toml on first start."
32 );
33 }
34
35 println!("things-mcp setup\n================\n");
36
37 let hostname = match detect_tailscale_dns_name() {
38 Ok(name) => name,
39 Err(e) => {
40 eprintln!("Could not detect Tailscale Funnel hostname: {e}\n");
41 eprintln!("To use this setup helper you need:");
42 eprintln!(" 1. Tailscale installed and signed in:");
43 eprintln!(" https://tailscale.com/download/macos");
44 eprintln!(" 2. Funnel enabled on your tailnet (admin action, once):");
45 eprintln!(" https://login.tailscale.com/admin/settings/features");
46 eprintln!(" 3. Then re-run `things-mcp setup`.");
47 anyhow::bail!("Tailscale not available");
48 }
49 };
50 let issuer = format!("https://{hostname}");
51 println!("Detected Tailscale Funnel hostname: {hostname}");
52 println!("OAuth issuer URL will be: {issuer}\n");
53 if !confirm("Use this hostname? [Y/n] ")? {
54 anyhow::bail!("aborted by user");
55 }
56
57 let binary_path = std::env::current_exe()
58 .map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))?;
59 let plist_path = launchd_plist_path()?;
60 let log_dir = home_dir()?.join(LOG_DIR_REL);
61 std::fs::create_dir_all(&log_dir)
62 .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", log_dir.display()))?;
63 let plist_body = render_plist(&binary_path, &issuer, &log_dir);
64
65 println!("Writing launchd plist → {}", plist_path.display());
66 if let Some(parent) = plist_path.parent() {
67 std::fs::create_dir_all(parent)?;
68 }
69 std::fs::write(&plist_path, plist_body)?;
70
71 println!("Reloading launchd job…");
72 reload_launchd(&plist_path)?;
73
74 println!("Enabling Tailscale Funnel on port {HTTP_PORT}…");
75 enable_tailscale_funnel(HTTP_PORT)?;
76
77 println!("Waiting for OAuth credentials to materialize…");
78 let oauth_path = oauth::config_path()
79 .ok_or_else(|| anyhow::anyhow!("could not resolve OAuth config path"))?;
80 wait_for_file(&oauth_path, Duration::from_secs(10))?;
81
82 let creds = read_oauth(&oauth_path)?;
83 print_credentials_block(&creds, &issuer);
84
85 Ok(())
86}
87
88pub async fn run_status() -> anyhow::Result<()> {
91 let mut all_ok = true;
92
93 println!("things-mcp status\n=================\n");
94
95 match launchd_job_loaded(PLIST_LABEL) {
97 Ok(true) => println!(" [OK] launchd job {PLIST_LABEL} loaded"),
98 Ok(false) => {
99 println!(" [FAIL] launchd job {PLIST_LABEL} not loaded");
100 println!(" fix: run `things-mcp setup`");
101 all_ok = false;
102 }
103 Err(e) => {
104 println!(" [FAIL] launchd check errored: {e}");
105 all_ok = false;
106 }
107 }
108
109 if tcp_port_listening("127.0.0.1", HTTP_PORT) {
111 println!(" [OK] HTTP server listening on {HTTP_BIND}");
112 } else {
113 println!(" [FAIL] no listener on {HTTP_BIND}");
114 println!(" fix: check ~/Library/Logs/things-mcp/http.err.log");
115 all_ok = false;
116 }
117
118 match tailscale_funnel_active(HTTP_PORT) {
120 Ok(true) => println!(" [OK] Tailscale Funnel is publishing port {HTTP_PORT}"),
121 Ok(false) => {
122 println!(" [FAIL] Tailscale Funnel is NOT publishing port {HTTP_PORT}");
123 println!(" fix: `tailscale funnel --bg {HTTP_PORT}`");
124 all_ok = false;
125 }
126 Err(e) => {
127 println!(" [WARN] could not check Tailscale Funnel: {e}");
128 }
129 }
130
131 if std::path::Path::new("/Applications/Things3.app").exists() {
133 println!(" [OK] Things 3 app present at /Applications/Things3.app");
134 } else {
135 println!(" [FAIL] Things 3 not installed at /Applications/Things3.app");
136 println!(" fix: install from https://culturedcode.com/things/");
137 all_ok = false;
138 }
139
140 match crate::core::config::resolve_db_path(
142 &mut crate::core::config::Config::default(),
143 None,
144 &home_dir()?,
145 ) {
146 Ok((db_path, _)) => match crate::core::reader::schema::probe(&db_path) {
147 Ok(()) => println!(" [OK] Things 3 database readable: {}", db_path.display()),
148 Err(e) => {
149 println!(" [FAIL] Things 3 database schema probe failed: {e}");
150 all_ok = false;
151 }
152 },
153 Err(e) => {
154 println!(" [FAIL] Could not resolve Things 3 database path: {e}");
155 all_ok = false;
156 }
157 }
158
159 let oauth_path = oauth::config_path()
161 .ok_or_else(|| anyhow::anyhow!("could not resolve OAuth config path"))?;
162 if oauth_path.exists() {
163 println!(" [OK] OAuth config present: {}", oauth_path.display());
164 } else {
165 println!(" [FAIL] OAuth config missing: {}", oauth_path.display());
166 println!(" fix: run `things-mcp setup`");
167 all_ok = false;
168 }
169
170 println!();
171 if all_ok {
172 println!("All green.");
173 Ok(())
174 } else {
175 anyhow::bail!("one or more checks failed")
176 }
177}
178
179pub fn run_show_credentials() -> anyhow::Result<()> {
182 let oauth_path = oauth::config_path()
183 .ok_or_else(|| anyhow::anyhow!("could not resolve OAuth config path"))?;
184 if !oauth_path.exists() {
185 anyhow::bail!(
186 "OAuth config not found at {}. Run `things-mcp setup` first.",
187 oauth_path.display()
188 );
189 }
190 let creds = read_oauth(&oauth_path)?;
191 print_credentials_block(&creds, &creds.issuer);
192 Ok(())
193}
194
195fn detect_tailscale_dns_name() -> anyhow::Result<String> {
198 let output = Command::new("tailscale")
199 .args(["status", "--json"])
200 .output()
201 .map_err(|e| anyhow::anyhow!("running `tailscale status --json`: {e}"))?;
202 if !output.status.success() {
203 anyhow::bail!(
204 "`tailscale status --json` exited {}: {}",
205 output.status,
206 String::from_utf8_lossy(&output.stderr).trim()
207 );
208 }
209 parse_tailscale_dns_name(&output.stdout)
210}
211
212fn parse_tailscale_dns_name(stdout: &[u8]) -> anyhow::Result<String> {
216 #[derive(serde::Deserialize)]
217 struct Status {
218 #[serde(rename = "Self")]
219 self_: SelfNode,
220 }
221 #[derive(serde::Deserialize)]
222 struct SelfNode {
223 #[serde(rename = "DNSName")]
224 dns_name: String,
225 }
226 let parsed: Status = serde_json::from_slice(stdout)
227 .map_err(|e| anyhow::anyhow!("parse Tailscale status JSON: {e}"))?;
228 let name = parsed.self_.dns_name.trim_end_matches('.').to_string();
229 if name.is_empty() {
230 anyhow::bail!("Tailscale Self.DNSName is empty");
231 }
232 Ok(name)
233}
234
235fn launchd_plist_path() -> anyhow::Result<PathBuf> {
236 Ok(home_dir()?
237 .join("Library/LaunchAgents")
238 .join(format!("{PLIST_LABEL}.plist")))
239}
240
241fn home_dir() -> anyhow::Result<PathBuf> {
242 directories::UserDirs::new()
243 .map(|u| u.home_dir().to_path_buf())
244 .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
245}
246
247fn render_plist(binary: &Path, issuer: &str, log_dir: &Path) -> String {
248 format!(
249 r#"<?xml version="1.0" encoding="UTF-8"?>
250<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
251<plist version="1.0">
252<dict>
253 <key>Label</key>
254 <string>{label}</string>
255
256 <key>ProgramArguments</key>
257 <array>
258 <string>/bin/sh</string>
259 <string>-c</string>
260 <string>exec {binary}</string>
261 </array>
262
263 <key>EnvironmentVariables</key>
264 <dict>
265 <key>THINGS_MCP_HTTP</key>
266 <string>{bind}</string>
267 <key>THINGS_MCP_OAUTH_ISSUER</key>
268 <string>{issuer}</string>
269 <key>RUST_LOG</key>
270 <string>info,tower_http=debug</string>
271 </dict>
272
273 <key>RunAtLoad</key>
274 <true/>
275 <key>KeepAlive</key>
276 <true/>
277 <key>ThrottleInterval</key>
278 <integer>10</integer>
279
280 <key>StandardOutPath</key>
281 <string>{out_log}</string>
282 <key>StandardErrorPath</key>
283 <string>{err_log}</string>
284</dict>
285</plist>
286"#,
287 label = PLIST_LABEL,
288 binary = binary.display(),
289 bind = HTTP_BIND,
290 issuer = issuer,
291 out_log = log_dir.join("http.out.log").display(),
292 err_log = log_dir.join("http.err.log").display(),
293 )
294}
295
296fn reload_launchd(plist_path: &Path) -> anyhow::Result<()> {
297 let uid = unsafe { libc_geteuid() };
298 let domain = format!("gui/{uid}");
299 let _ = Command::new("launchctl")
301 .args(["bootout", &domain, &plist_path.display().to_string()])
302 .output();
303 let out = Command::new("launchctl")
304 .args(["bootstrap", &domain, &plist_path.display().to_string()])
305 .output()
306 .map_err(|e| anyhow::anyhow!("running launchctl bootstrap: {e}"))?;
307 if !out.status.success() {
308 anyhow::bail!(
309 "launchctl bootstrap failed: {}",
310 String::from_utf8_lossy(&out.stderr).trim()
311 );
312 }
313 Ok(())
314}
315
316fn enable_tailscale_funnel(port: u16) -> anyhow::Result<()> {
317 let out = Command::new("tailscale")
318 .args(["funnel", "--bg", &port.to_string()])
319 .output()
320 .map_err(|e| anyhow::anyhow!("running `tailscale funnel`: {e}"))?;
321 if !out.status.success() {
322 let stderr = String::from_utf8_lossy(&out.stderr);
323 if stderr.contains("already") {
325 return Ok(());
326 }
327 anyhow::bail!("tailscale funnel exited {}: {}", out.status, stderr.trim());
328 }
329 Ok(())
330}
331
332fn wait_for_file(path: &Path, timeout: Duration) -> anyhow::Result<()> {
333 let start = Instant::now();
334 while start.elapsed() < timeout {
335 if path.exists() {
336 return Ok(());
337 }
338 std::thread::sleep(Duration::from_millis(200));
339 }
340 anyhow::bail!(
341 "timed out after {:?} waiting for {} — check ~/Library/Logs/things-mcp/http.err.log",
342 timeout,
343 path.display()
344 )
345}
346
347fn read_oauth(path: &Path) -> anyhow::Result<oauth::OAuthConfig> {
348 let bytes = std::fs::read(path).map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
349 let cfg: oauth::OAuthConfig = toml::from_str(std::str::from_utf8(&bytes)?)?;
350 Ok(cfg)
351}
352
353fn print_credentials_block(creds: &oauth::OAuthConfig, issuer: &str) {
354 let url = format!("{issuer}/mcp");
355 println!("\n=== Paste these into Claude.ai → Settings → Connectors → Add custom ===\n");
356 println!(" Server URL {url}");
357 println!(" Advanced ▸ Client ID {}", creds.client_id);
358 println!(" Advanced ▸ Client Secret {}", creds.client_secret);
359 println!();
360}
361
362fn launchd_job_loaded(label: &str) -> anyhow::Result<bool> {
363 let uid = unsafe { libc_geteuid() };
364 let out = Command::new("launchctl")
365 .args(["print", &format!("gui/{uid}/{label}")])
366 .output()
367 .map_err(|e| anyhow::anyhow!("running launchctl print: {e}"))?;
368 Ok(out.status.success())
369}
370
371fn tcp_port_listening(host: &str, port: u16) -> bool {
372 std::net::TcpStream::connect_timeout(
373 &format!("{host}:{port}")
374 .parse()
375 .expect("hardcoded host:port"),
376 Duration::from_millis(500),
377 )
378 .is_ok()
379}
380
381fn tailscale_funnel_active(port: u16) -> anyhow::Result<bool> {
382 let out = Command::new("tailscale")
383 .args(["funnel", "status"])
384 .output()
385 .map_err(|e| anyhow::anyhow!("running `tailscale funnel status`: {e}"))?;
386 if !out.status.success() {
387 anyhow::bail!("`tailscale funnel status` exited {}", out.status);
388 }
389 let stdout = String::from_utf8_lossy(&out.stdout);
390 Ok(stdout.contains(&port.to_string()))
392}
393
394fn confirm(prompt: &str) -> anyhow::Result<bool> {
395 print!("{prompt}");
396 io::stdout().flush()?;
397 let mut line = String::new();
398 io::stdin().lock().read_line(&mut line)?;
399 let trimmed = line.trim();
400 Ok(trimmed.is_empty() || matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes"))
401}
402
403extern "C" {
406 fn geteuid() -> u32;
407}
408unsafe fn libc_geteuid() -> u32 {
409 geteuid()
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn parses_dns_name_with_trailing_dot() {
418 let json = br#"{
419 "Self": { "DNSName": "laptop.stoat-minnow.ts.net.", "HostName": "Whatever" },
420 "MagicDNSSuffix": "stoat-minnow.ts.net"
421 }"#;
422 let name = parse_tailscale_dns_name(json).unwrap();
423 assert_eq!(name, "laptop.stoat-minnow.ts.net");
424 }
425
426 #[test]
427 fn parses_dns_name_without_trailing_dot() {
428 let json = br#"{ "Self": { "DNSName": "machine.tail-net.ts.net", "HostName": "x" } }"#;
429 assert_eq!(
430 parse_tailscale_dns_name(json).unwrap(),
431 "machine.tail-net.ts.net"
432 );
433 }
434
435 #[test]
436 fn rejects_empty_dns_name() {
437 let json = br#"{ "Self": { "DNSName": "", "HostName": "x" } }"#;
438 assert!(parse_tailscale_dns_name(json).is_err());
439 }
440
441 #[test]
442 fn plist_template_substitutes_paths_and_issuer() {
443 let plist = render_plist(
444 Path::new("/opt/bin/things-mcp"),
445 "https://example.test",
446 Path::new("/var/log/things-mcp"),
447 );
448 assert!(plist.contains("<string>exec /opt/bin/things-mcp</string>"));
449 assert!(plist.contains("<string>https://example.test</string>"));
450 assert!(plist.contains("<string>/var/log/things-mcp/http.out.log</string>"));
451 assert!(plist.contains("<string>/var/log/things-mcp/http.err.log</string>"));
452 assert!(plist.contains("<string>com.things-mcp.http</string>"));
453 }
454}