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    identity_conflict: Option<std::sync::Arc<crate::diag::IdentityConflict>>,
59}
60
61impl NodeBuilder {
62    /// A node rooted at `root` — the ONE directory holding its whole world (`config/`,
63    /// `data/`, `state/`; layout-identical to a `mcpmesh --profile <root>` profile dir).
64    /// Missing pieces are created on start: the first start mints the device key, and an
65    /// absent `config/config.toml` boots the spec defaults.
66    pub fn new(root: impl Into<PathBuf>) -> Self {
67        Self {
68            root: root.into(),
69            config: None,
70            identity_conflict: None,
71        }
72    }
73
74    /// Use this configuration instead of reading `<root>/config/config.toml`. The type IS
75    /// the config-file vocabulary (`docs/config.md`) — one schema, two front doors.
76    /// Config-persisting control verbs (a non-ephemeral `register_service`, pairing
77    /// grants) still write `<root>/config/config.toml`.
78    pub fn config(mut self, config: Config) -> Self {
79        self.config = Some(config);
80        self
81    }
82
83    /// Share the duplicate-identity observation with the host's `tracing` subscriber (#134).
84    ///
85    /// Two nodes booted from COPIES of one mesh root present the same endpoint id; the relay can
86    /// serve only one, and the displaced node's peers go unreachable with nothing saying why. iroh
87    /// 1.0.3 exposes that report **only as a log event**, so detecting it needs a layer in the
88    /// process's subscriber — and an embedded node cannot install one, because the subscriber is
89    /// global and your application owns it.
90    ///
91    /// Pass the SAME `Arc` you gave [`IdentityConflictLayer`](crate::diag::IdentityConflictLayer),
92    /// so that what the layer records is what this node's `status` reports:
93    ///
94    /// ```ignore
95    /// use std::sync::Arc;
96    /// use tracing_subscriber::prelude::*;
97    /// use mcpmesh_node::diag::{IdentityConflict, IdentityConflictLayer};
98    ///
99    /// let conflict = Arc::new(IdentityConflict::default());
100    /// tracing_subscriber::registry()
101    ///     .with(my_fmt_layer)
102    ///     .with(IdentityConflictLayer::new(conflict.clone()))
103    ///     .init();
104    ///
105    /// let node = NodeBuilder::new(root).identity_conflict(conflict).start().await?;
106    /// ```
107    ///
108    /// Without it, `status.self_network.identity_conflict_epoch` is always absent — which means
109    /// "not observable here", NOT "this identity is unique". Nothing else changes: the node boots,
110    /// serves, and behaves identically either way.
111    pub fn identity_conflict(
112        mut self,
113        shared: std::sync::Arc<crate::diag::IdentityConflict>,
114    ) -> Self {
115        self.identity_conflict = Some(shared);
116        self
117    }
118
119    /// Boot the node: identity, stores, gates, the iroh endpoint, and every serving loop
120    /// the daemon runs. Requires a multi-thread tokio runtime (the node spawns its serving
121    /// loops onto the ambient runtime). Installs a process-default rustls `CryptoProvider`
122    /// (ring) if the host application has not installed one — idempotent, the host's wins.
123    pub async fn start(self) -> Result<Node, StartError> {
124        let paths = NodePaths::under_root(&self.root);
125        let booted = start_node(paths, self.config).await?;
126        // #134: adopt the host's shared observation, so the layer IN THEIR subscriber and this
127        // node's `status` read the same cell. Set after boot rather than threaded through it —
128        // the field is only ever read by the status projection, never during construction.
129        if let (Some(shared), Some(mesh)) = (self.identity_conflict, booted.state.mesh()) {
130            mesh.adopt_identity_conflict(shared);
131        }
132        Ok(Node { booted })
133    }
134}
135
136/// A running in-process node. Dropping it does NOT stop serving — call
137/// [`shutdown`](Node::shutdown).
138pub struct Node {
139    booted: BootedNode,
140}
141
142/// Hand-rolled: the boot internals are not `Debug`; the identity is the one diagnostic
143/// a `{:?}` needs.
144impl std::fmt::Debug for Node {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("Node")
147            .field("endpoint_id", &self.endpoint_id())
148            .finish_non_exhaustive()
149    }
150}
151
152impl Node {
153    /// A control connection to THIS node: the same typed `mcpmesh-local/1` client a
154    /// sidecar consumer gets from `connect_control_default`, over an in-memory pipe.
155    /// Cheap; open one per concurrent conversation — a session/stream upgrade
156    /// (`open_session`, `subscribe`) consumes its connection, exactly as on the socket.
157    pub async fn control(&self) -> Result<ControlClient, ClientError> {
158        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
159        let (server_read, server_write) = tokio::io::split(server_io);
160        let state = self.booted.state.clone();
161        let track_state = state.clone();
162        let handle = tokio::spawn(async move {
163            if let Err(e) = serve_control_io(server_read, server_write, state).await {
164                tracing::debug!(%e, "in-process control connection ended");
165            }
166        });
167        // Tracked like a socket connection's serving task (`serve_control`'s per-connection
168        // spawn): without this, an attached `subscribe()` stream never notices `shutdown` (see
169        // `Node::shutdown`) and outlives the node, holding its `Arc<DaemonState>`/mesh/redb lock
170        // open.
171        track_state.track_control_task(handle);
172        let (client_read, client_write) = tokio::io::split(client_io);
173        connect_control_io(client_read, client_write).await
174    }
175
176    /// This node's mesh identity — what a peer's invite/pair flow binds to.
177    pub fn endpoint_id(&self) -> iroh::EndpointId {
178        self.mesh().endpoint.id()
179    }
180
181    /// Sign an application payload with this node's DEVICE key, under the embedder's own
182    /// `domain` (#59).
183    ///
184    /// **What this is for.** mcpmesh authenticates the transport: inside a session,
185    /// `_meta["mcpmesh/peer"]` says who is calling. That answers nothing about a payload which
186    /// outlives its connection — anything store-and-forward (offline delivery, a relay, a mailbox,
187    /// an app-level overlay) handles bytes whose author is not the peer that delivered them, and
188    /// the transport authenticated the FORWARDER. This attributes the ORIGIN, against the same
189    /// identity the transport already proves, so an embedder needs no second key, no second
190    /// backup/revocation story, and no binding protocol tying the two together.
191    ///
192    /// **`domain` is yours; pick one per statement KIND** (`b"chat/message/1"`,
193    /// `b"mailbox/receipt/1"`). A signature is only as narrow as its domain, and sharing one across
194    /// two shapes lets a value from either be read as the other. mcpmesh's own domains are out of
195    /// reach whatever you choose — see [`mcpmesh_trust::app`] for why that is a property of the
196    /// preimage rather than of your discipline.
197    ///
198    /// Verify with [`verify_app`](Self::verify_app), which needs no node.
199    ///
200    /// **Not a control verb, deliberately.** Signing over the JSON-RPC socket would put the device
201    /// key's authority behind an IPC surface shared by every consumer of that socket. This is an
202    /// in-process seam for the embedder that owns the node.
203    pub fn sign_app(&self, domain: &[u8], msg: &[u8]) -> [u8; 64] {
204        // Derived from the endpoint rather than held as a field: the signing key is then the one
205        // whose public half IS `endpoint_id()`, by construction. A separately-stored copy could be
206        // absent or stale, and a signing API that fails open or signs under the wrong identity is
207        // worse than none.
208        //
209        // Hardening note, the same residual `DeviceKey::secret_bytes` documents: `to_bytes()` hands
210        // back a plain `[u8; 32]` that is not zeroized, and the `SigningKey` built from it is
211        // scrubbed on drop but the array is not. Per call rather than once — accepted, because the
212        // alternative is caching the key material for the node's whole life, which is a larger
213        // residual, not a smaller one.
214        let signing = mcpmesh_trust::ed25519_dalek::SigningKey::from_bytes(
215            &self.mesh().endpoint.secret_key().to_bytes(),
216        );
217        mcpmesh_trust::sign_app(&signing, domain, msg)
218    }
219
220    /// Verify an application payload signed by `endpoint_id` under `domain` (#59).
221    ///
222    /// An associated function: verification needs no node, which is the point — a consumer checking
223    /// a relayed payload has the peer's `EndpointId` and nothing else.
224    ///
225    /// Returns `false` for a bad signature, a mismatched domain/message, or malformed bytes. It
226    /// never panics: every input is attacker-supplied by construction.
227    ///
228    /// It answers "which device produced these bytes" and nothing else. Whether that device was
229    /// ENTITLED to make the statement is the embedder's authorization question, answered from the
230    /// embedder's own state.
231    pub fn verify_app(
232        endpoint_id: &iroh::EndpointId,
233        domain: &[u8],
234        msg: &[u8],
235        sig: &[u8; 64],
236    ) -> bool {
237        mcpmesh_trust::verify_app(endpoint_id.as_bytes(), domain, msg, sig)
238    }
239
240    /// Resolves once shutdown has been requested — by [`shutdown`](Node::shutdown) from
241    /// another handle, or by the control protocol's `shutdown` verb (e.g. an operator
242    /// driving this node's control connection).
243    pub async fn wait(&self) {
244        self.booted.state.shutdown_requested().await;
245    }
246
247    /// Stop serving: raise the shutdown signal, stop the accept/poll/background loops and
248    /// every live control connection (subscription streams end immediately; in-flight control
249    /// requests get a dropped connection — acceptable, shutdown means shutdown), and close the
250    /// endpoint (a graceful QUIC close — live sessions end cleanly).
251    pub async fn shutdown(self) {
252        // One teardown path, shared with the boot tests (#105) so neither can drift from the other.
253        crate::daemon::boot::shutdown_booted(self.booted).await;
254    }
255
256    fn mesh(&self) -> &Arc<crate::daemon::MeshState> {
257        self.booted
258            .state
259            .mesh()
260            .expect("a started Node always owns a mesh")
261    }
262}