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}