Skip to main content

things_mcp/
setup.rs

1//! Interactive setup for the streamable-HTTP + OAuth deployment.
2//!
3//! The MCP server itself is just a long-running daemon; what makes it hard to
4//! install is the surrounding macOS plumbing — launchd plist, Tailscale Funnel,
5//! Things 3 database, paste-friendly OAuth credentials. This module collapses
6//! all of that into `things-mcp setup` so a user goes from `cargo install` to
7//! "Claude.ai is talking to my library" in ~30 seconds.
8//!
9//! macOS-only by design (launchd). The Linux path would need a systemd unit;
10//! deferred until someone asks.
11
12use 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
24// ---------- setup --------------------------------------------------------
25
26pub 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
88// ---------- status -------------------------------------------------------
89
90pub async fn run_status() -> anyhow::Result<()> {
91    let mut all_ok = true;
92
93    println!("things-mcp status\n=================\n");
94
95    // launchd
96    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    // HTTP server
110    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    // Tailscale Funnel
119    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    // Things 3 app
132    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    // Things 3 SQLite database
141    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    // oauth.toml
160    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
179// ---------- show-credentials --------------------------------------------
180
181pub 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
195// ---------- helpers ------------------------------------------------------
196
197fn 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
212/// Pull `Self.DNSName` out of the JSON returned by `tailscale status --json`,
213/// strip the trailing dot (FQDN convention), and return e.g.
214/// `laptop.stoat-minnow.ts.net`.
215fn 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    // bootout is fine to fail (job may not be loaded yet); ignore status.
300    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        // Funnel may already be enabled — that's fine.
324        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    // Output mentions the port number when something is being served on it.
391    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
403// libc::geteuid wrapper — we just need the user id for the launchctl domain
404// string; bringing in the full libc crate for one call is excessive.
405extern "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}