Skip to main content

mcpmesh_node/
node.rs

1//! The supported embedding surface: build ([`NodeBuilder`]) and drive ([`Node`]) a full
2//! in-process mesh node. The node is its OWN mesh identity under its OWN root directory —
3//! it never touches the per-user daemon's state, socket, or singleton lock, so it coexists
4//! freely with a running `mcpmesh` daemon (and with other embedded nodes under other roots).
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use mcpmesh_local_api::client::ClientError;
9use mcpmesh_local_api::{ControlClient, connect_control_io};
10
11use crate::config::Config;
12use crate::control::serve_control_io;
13use crate::daemon::boot::{BootedNode, start_node};
14use crate::paths::NodePaths;
15
16/// Everything that can refuse a [`NodeBuilder::start`]. Embedders branch on
17/// [`DataDirInUse`](StartError::DataDirInUse) (another node owns this root — one node per
18/// root, enforced by redb's exclusive database lock) and [`Config`](StartError::Config)
19/// (a malformed `config.toml` / programmatic config, worth showing to a human); everything
20/// else is opaque infrastructure failure.
21#[derive(Debug, thiserror::Error)]
22pub enum StartError {
23    #[error("config error: {0:#}")]
24    Config(#[source] anyhow::Error),
25    #[error("data dir already in use by another node: {path}")]
26    DataDirInUse { path: PathBuf },
27    #[error(transparent)]
28    Other(anyhow::Error),
29}
30
31impl StartError {
32    /// Classify a boot error by its CHAIN (the boot body stays plain-`anyhow`, so inner
33    /// `?` sites never re-wrap): a `redb` open refusal on the peer store → `DataDirInUse`
34    /// (its exact variant differs by platform/lock path, so any database-open error on the
35    /// store path counts); a `figment` error anywhere → `Config`; else `Other`.
36    pub(crate) fn classify(e: anyhow::Error, _config_path: &Path, db_path: &Path) -> StartError {
37        if e.chain()
38            .any(|c| c.downcast_ref::<redb::DatabaseError>().is_some())
39        {
40            return StartError::DataDirInUse {
41                path: db_path.to_path_buf(),
42            };
43        }
44        if e.chain()
45            .any(|c| c.downcast_ref::<figment::Error>().is_some())
46        {
47            return StartError::Config(e);
48        }
49        StartError::Other(e)
50    }
51}
52
53/// Build a [`Node`]: pick a root directory, optionally inject a [`Config`], then
54/// [`start`](NodeBuilder::start).
55pub struct NodeBuilder {
56    root: PathBuf,
57    config: Option<Config>,
58}
59
60impl NodeBuilder {
61    /// A node rooted at `root` — the ONE directory holding its whole world (`config/`,
62    /// `data/`, `state/`; layout-identical to a `mcpmesh --profile <root>` profile dir).
63    /// Missing pieces are created on start: the first start mints the device key, and an
64    /// absent `config/config.toml` boots the spec defaults.
65    pub fn new(root: impl Into<PathBuf>) -> Self {
66        Self {
67            root: root.into(),
68            config: None,
69        }
70    }
71
72    /// Use this configuration instead of reading `<root>/config/config.toml`. The type IS
73    /// the config-file vocabulary (`docs/config.md`) — one schema, two front doors.
74    /// Config-persisting control verbs (a non-ephemeral `register_service`, pairing
75    /// grants) still write `<root>/config/config.toml`.
76    pub fn config(mut self, config: Config) -> Self {
77        self.config = Some(config);
78        self
79    }
80
81    /// Boot the node: identity, stores, gates, the iroh endpoint, and every serving loop
82    /// the daemon runs. Requires a multi-thread tokio runtime (the node spawns its serving
83    /// loops onto the ambient runtime). Installs a process-default rustls `CryptoProvider`
84    /// (ring) if the host application has not installed one — idempotent, the host's wins.
85    pub async fn start(self) -> Result<Node, StartError> {
86        let paths = NodePaths::under_root(&self.root);
87        let booted = start_node(paths, self.config).await?;
88        Ok(Node { booted })
89    }
90}
91
92/// A running in-process node. Dropping it does NOT stop serving — call
93/// [`shutdown`](Node::shutdown).
94pub struct Node {
95    booted: BootedNode,
96}
97
98/// Hand-rolled: the boot internals are not `Debug`; the identity is the one diagnostic
99/// a `{:?}` needs.
100impl std::fmt::Debug for Node {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("Node")
103            .field("endpoint_id", &self.endpoint_id())
104            .finish_non_exhaustive()
105    }
106}
107
108impl Node {
109    /// A control connection to THIS node: the same typed `mcpmesh-local/1` client a
110    /// sidecar consumer gets from `connect_control_default`, over an in-memory pipe.
111    /// Cheap; open one per concurrent conversation — a session/stream upgrade
112    /// (`open_session`, `subscribe`) consumes its connection, exactly as on the socket.
113    pub async fn control(&self) -> Result<ControlClient, ClientError> {
114        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
115        let (server_read, server_write) = tokio::io::split(server_io);
116        let state = self.booted.state.clone();
117        tokio::spawn(async move {
118            if let Err(e) = serve_control_io(server_read, server_write, state).await {
119                tracing::debug!(%e, "in-process control connection ended");
120            }
121        });
122        let (client_read, client_write) = tokio::io::split(client_io);
123        connect_control_io(client_read, client_write).await
124    }
125
126    /// This node's mesh identity — what a peer's invite/pair flow binds to.
127    pub fn endpoint_id(&self) -> iroh::EndpointId {
128        self.mesh().endpoint.id()
129    }
130
131    /// Resolves once shutdown has been requested — by [`shutdown`](Node::shutdown) from
132    /// another handle, or by the control protocol's `shutdown` verb (e.g. an operator
133    /// driving this node's control connection).
134    pub async fn wait(&self) {
135        self.booted.state.shutdown_requested().await;
136    }
137
138    /// Stop serving: raise the shutdown signal, stop the accept/poll/background loops,
139    /// and close the endpoint (a graceful QUIC close — live sessions end cleanly).
140    pub async fn shutdown(self) {
141        let state = &self.booted.state;
142        state.request_shutdown();
143        let mesh = self
144            .booted
145            .state
146            .mesh()
147            .expect("a started Node always owns a mesh")
148            .clone();
149        if let Some(task) = mesh.accept_task.lock().await.take() {
150            task.abort();
151        }
152        if let Some(task) = mesh.poll_loop.lock().await.take() {
153            task.abort();
154        }
155        for task in self.booted.background {
156            task.abort();
157        }
158        mesh.endpoint.close().await;
159    }
160
161    fn mesh(&self) -> &Arc<crate::daemon::MeshState> {
162        self.booted
163            .state
164            .mesh()
165            .expect("a started Node always owns a mesh")
166    }
167}