Skip to main content

origin_mcp_http/
discovery.rs

1//! Publishing how to reach an endpoint, and reading it back.
2//!
3//! The port is chosen at runtime, so a client cannot know it in advance. The GUI
4//! writes this file when it starts the HTTP adapter; a headless start reads it to
5//! discover that a GUI is already serving (G17).
6
7use origin_domain::{AppError, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// How a client reaches a running instance.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Discovery {
14    /// Full URL, e.g. `http://127.0.0.1:54321/mcp`.
15    pub url: String,
16    /// The bearer token the endpoint expects (G19).
17    pub token: String,
18}
19
20impl Discovery {
21    /// Write the file atomically (write then rename) so a reader never sees a
22    /// half-written file.
23    pub fn write(&self, path: &Path) -> Result<()> {
24        if let Some(parent) = path.parent() {
25            std::fs::create_dir_all(parent).map_err(|error| {
26                AppError::storage(format!("cannot create {}: {error}", parent.display()))
27            })?;
28        }
29
30        let encoded = serde_json::to_string_pretty(self)
31            .map_err(|error| AppError::storage(format!("cannot encode discovery: {error}")))?;
32
33        let temporary = path.with_extension("json.tmp");
34        std::fs::write(&temporary, encoded).map_err(|error| {
35            AppError::storage(format!("cannot write {}: {error}", temporary.display()))
36        })?;
37        std::fs::rename(&temporary, path).map_err(|error| {
38            AppError::storage(format!("cannot publish {}: {error}", path.display()))
39        })?;
40
41        tracing::debug!(path = %path.display(), "mcp http endpoint published");
42        Ok(())
43    }
44
45    /// Read the file, or `None` when it does not exist or is unreadable.
46    ///
47    /// A stale or corrupt file is not an error: the caller simply falls back to
48    /// starting its own transport.
49    pub fn read(path: &Path) -> Option<Self> {
50        let contents = std::fs::read_to_string(path).ok()?;
51        serde_json::from_str(&contents).ok()
52    }
53
54    /// Remove the file. Missing is fine.
55    pub fn remove(path: &Path) {
56        let _ = std::fs::remove_file(path);
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    fn temp_path(name: &str) -> std::path::PathBuf {
65        let unique = format!("origin-mcp-discovery-{}-{name}.json", std::process::id());
66        std::env::temp_dir().join(unique)
67    }
68
69    #[test]
70    fn a_discovery_round_trips() {
71        let path = temp_path("round-trip");
72        let discovery = Discovery {
73            url: "http://127.0.0.1:5000/mcp".to_owned(),
74            token: "abc".to_owned(),
75        };
76
77        discovery.write(&path).unwrap();
78        let read = Discovery::read(&path).expect("must read back");
79
80        assert_eq!(read, discovery);
81        Discovery::remove(&path);
82        assert!(Discovery::read(&path).is_none());
83    }
84
85    #[test]
86    fn a_missing_file_reads_as_none() {
87        assert!(Discovery::read(&temp_path("missing")).is_none());
88    }
89}