Skip to main content

mcpmesh_local_api/
service.rs

1//! The shared plugin-platform seam (`service` feature): everything a plugin daemon
2//! (kb, loc, …) needs to face the platform, extracted from the kb/loc byte-duplicates so
3//! each rule has ONE home:
4//!
5//! - UDS faces: [`ensure_private_dir`] + [`bind_uds`] + [`check_peer_uid`] (0700
6//!   symlink-refused owned runtime dir, 0600 socket, same-uid gate). The mcpmesh daemon's
7//!   own control socket (`cli/src/ipc.rs`) binds through the SAME rule.
8//! - THE audience-authz expansion: [`peer_audiences`] — `groups ∪ {eid} ∪ {user_id}`,
9//!   default-deny. The single implementation both kb and loc gate on.
10//! - `[services.*]` self-registration: [`register_service`] (empty allowlist; failures
11//!   logged, never silently swallowed).
12//! - `*-local/1` JSON-RPC conventions: [`ok`]/[`err`]/[`reply`]/[`internal`], the strict
13//!   [`required_string_array`] param parse, and [`people_from_status`].
14//! - the `*-local/1` Hello first frame: [`send_hello`].
15//!
16//! Deliberately NOT here: mcpmesh control-endpoint resolution. That is the featureless
17//! [`crate::paths`] rule ([`crate::paths::default_endpoint`]) — ONE home for daemon, CLI,
18//! and plugins, correct on both platforms (a named pipe on windows, never a joined
19//! filesystem path).
20//!
21//! Deliberately NOT extracted (KISS until a third plugin proves the abstraction): state
22//! models, Paths structs, tool dispatch/specs, fan-out policy, and each plugin's MCP
23//! session skeleton in `remote.rs`.
24//!
25//! [`ensure_private_dir`]: crate::service::ensure_private_dir
26//! [`bind_uds`]: crate::service::bind_uds
27//! [`check_peer_uid`]: crate::service::check_peer_uid
28//! [`peer_audiences`]: crate::service::peer_audiences
29//! [`register_service`]: crate::service::register_service
30//! [`ok`]: crate::service::ok
31//! [`err`]: crate::service::err
32//! [`reply`]: crate::service::reply
33//! [`internal`]: crate::service::internal
34//! [`required_string_array`]: crate::service::required_string_array
35//! [`people_from_status`]: crate::service::people_from_status
36//! [`send_hello`]: crate::service::send_hello
37use std::io;
38use std::path::Path;
39
40use serde_json::{Value, json};
41use tokio::io::AsyncWrite;
42// The UDS-face check is exercised by the unix test module below (`UnixStream::connect`);
43// non-test code reaches these faces through `crate::transport`, so the import is test-only
44// and unix-only (the windows transport has no UDS fixtures).
45#[cfg(all(test, unix))]
46use tokio::net::UnixStream;
47
48use crate::client::{ClientError, connect_control};
49use crate::codec::write_frame;
50use crate::protocol::{BackendSpec, Hello, RegisterServiceParams, Request};
51
52// ---------------------------------------------------------------------------------------
53// UDS faces
54// ---------------------------------------------------------------------------------------
55
56// The implementation now lives in `crate::transport` (the platform local-endpoint seam):
57// on unix it is the SAME hardened UDS rule, moved verbatim. These unix-native names remain
58// the plugin API on unix — the private monorepo consumes
59// `mcpmesh_local_api::service::{ensure_private_dir, bind_uds, check_peer_uid}`, so they are
60// re-exported here with identical signatures. Unix-only: `bind_uds`/`check_peer_uid`/
61// `ensure_private_dir` are the UDS hardening rule (0700 dir, 0600 socket, peer-euid
62// gate) and have no meaning on Windows, where the pipe's owner-only DACL is the whole
63// gate (see `transport::windows`). The plugin consumers (kb, loc) are unix today.
64#[cfg(unix)]
65pub use crate::transport::{bind_uds, check_peer_uid, ensure_private_dir};
66
67// ---------------------------------------------------------------------------------------
68// THE audience-authz expansion (default-deny)
69// ---------------------------------------------------------------------------------------
70
71/// `peer_audiences = peer.groups ∪ {peer.eid} ∪ {peer.user_id}` — THE ONE
72/// implementation of the caller-audience expansion every plugin gates on (kb re-exports it
73/// as `effective_audiences`). An absent/empty peer yields an EMPTY set — default deny.
74///
75/// Never trusts a self-asserted value: the whole peer object is the platform-injected,
76/// forge-proof `_meta["mcpmesh/peer"]` (the mcpmesh daemon authoritatively OVERWRITES it, so
77/// `groups`/`user_id` can't be caller-forged).
78///
79/// `user_id` is the person's self-sovereign id (`b64u:<user_pk>`, present once a device→user
80/// binding is verified — pairing OR roster). Including it means content shared to a PERSON
81/// reaches ALL their devices (each presents the same verified user_id under a distinct
82/// nickname), whereas `eid` (the stable device principal) scopes to one device and `groups`
83/// to a roster set — three legitimate granularities.
84///
85/// Re-keyed on stable identity in 0.8.0 (#38): the platform injection carries the device
86/// `eid:` principal and the display `name` is NEVER an audience — the identity hardening
87/// this doc long promised, landed here, once.
88pub fn peer_audiences(peer: &Value) -> Vec<String> {
89    // The expansion itself is THE shared `principal_set` (crate::principals — the flat
90    // namespace, one implementation for the mesh allow check, this seam, and the blob-scope
91    // gate); this fn only adapts the platform-injected peer JSON onto it.
92    let groups: Vec<String> = peer
93        .get("groups")
94        .and_then(|g| g.as_array())
95        .map(|arr| {
96            arr.iter()
97                .filter_map(|g| g.as_str().map(str::to_owned))
98                .collect()
99        })
100        .unwrap_or_default();
101    crate::principal_set(
102        peer.get("eid").and_then(|v| v.as_str()),
103        peer.get("user_id").and_then(|v| v.as_str()),
104        &groups,
105    )
106    .into_iter()
107    .map(str::to_owned)
108    .collect()
109}
110
111// ---------------------------------------------------------------------------------------
112// [services.*] self-registration
113// ---------------------------------------------------------------------------------------
114
115/// Register (or idempotently update) `[services.<service_name>]` on the running mcpmesh
116/// daemon: a SOCKET backend pointing at `backend_sock`, with an EMPTY allowlist — local-only
117/// until the user explicitly grants a peer (reachability is a user grant; the
118/// content itself is gated per-audience inside each plugin's service).
119///
120/// A failure is ALWAYS logged here (`tracing::warn`) before being returned, so a
121/// daemon treating registration as best-effort (`let _ =` — the mcpmesh daemon may not be up
122/// in a headless test) can never silently swallow it.
123pub async fn register_service(
124    control_sock: &Path,
125    service_name: &str,
126    backend_sock: &Path,
127) -> Result<(), ClientError> {
128    let result = async {
129        let mut client = connect_control(control_sock).await?;
130        client
131            .request(Request::RegisterService(RegisterServiceParams {
132                name: service_name.to_string(),
133                backend: BackendSpec::Socket {
134                    path: backend_sock.to_string_lossy().into_owned(),
135                },
136                allow: vec![],
137                // This helper connects → registers → disconnects, so it MUST be persistent: an
138                // ephemeral registration would be torn down the instant this connection closes.
139                // Ephemeral (#36) is for embedders that hold a ControlClient open for the
140                // service's lifetime (see ControlClient::register_service_with).
141                ephemeral: false,
142                rate_limit_per_min: None,
143            }))
144            .await?;
145        Ok(())
146    }
147    .await;
148    if let Err(e) = &result {
149        tracing::warn!(
150            service = service_name,
151            control_sock = %control_sock.display(),
152            error = %e,
153            "mcpmesh service registration failed — service stays unregistered until the daemon restarts"
154        );
155    }
156    result
157}
158
159// ---------------------------------------------------------------------------------------
160// *-local/1 JSON-RPC conventions
161// ---------------------------------------------------------------------------------------
162
163/// JSON-RPC error code: invalid params. (An unknown method answers `-32601`, the standard
164/// JSON-RPC code — see `docs/local-protocol.md` "Error codes".)
165pub const ERR_PARAMS: i64 = -32602;
166/// JSON-RPC error code: internal error.
167pub const ERR_INTERNAL: i64 = -32603;
168
169/// A JSON-RPC success frame (absent id → null, the notification-shaped degenerate case).
170pub fn ok(id: Option<Value>, result: Value) -> Value {
171    json!({"jsonrpc":"2.0","id": id.unwrap_or(Value::Null),"result": result})
172}
173
174/// A JSON-RPC error frame.
175pub fn err(id: Option<Value>, code: i64, message: &str) -> Value {
176    json!({"jsonrpc":"2.0","id": id.unwrap_or(Value::Null),"error":{"code":code,"message":message}})
177}
178
179/// Wrap a handler's `Result` into a JSON-RPC response frame (the `*-local/1` dispatch shape).
180pub fn reply(id: Value, r: Result<Value, (i64, String)>) -> Value {
181    match r {
182        Ok(v) => json!({"jsonrpc":"2.0","id":id,"result":v}),
183        Err((code, message)) => {
184            json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}})
185        }
186    }
187}
188
189/// Map an internal failure to `(ERR_INTERNAL, "internal error")`: log the detail locally,
190/// NEVER echo it to the caller (a retriever IO error may embed a filesystem path — e.g. a
191/// hashed audience dir — that must not reach a peer or even the owner surface).
192pub fn internal(e: impl std::fmt::Display) -> (i64, String) {
193    tracing::warn!(error = %e, "internal error (detail withheld from the caller)");
194    (ERR_INTERNAL, "internal error".to_string())
195}
196
197/// STRICT `params[key]` string-array parse: the key must be present, an array, and every
198/// element a string — anything else is `ERR_PARAMS`. Destructive setters (share lists) MUST
199/// use this: a lenient `unwrap_or_default()` would read a malformed request as "share with
200/// NOBODY" and persist `[]` — an accidental unshare-everyone.
201pub fn required_string_array(params: &Value, key: &str) -> Result<Vec<String>, (i64, String)> {
202    let arr = params
203        .get(key)
204        .and_then(|v| v.as_array())
205        .ok_or((ERR_PARAMS, format!("{key} (array of strings) is required")))?;
206    arr.iter()
207        .map(|v| {
208            v.as_str()
209                .map(str::to_owned)
210                .ok_or((ERR_PARAMS, format!("{key} must contain only strings")))
211        })
212        .collect()
213}
214
215/// Extract the friendly people directory from an mcpmesh `status` result (`share_targets`):
216/// one entry per paired peer — the owner's nickname for it + its verified `user_id` (or
217/// null). Pure over the JSON so it is unit-tested without a live mcpmesh. Surface-clean:
218/// nickname + user_id only, never a transport id / service list.
219pub fn people_from_status(status: &Value) -> Vec<Value> {
220    status["peers"]
221        .as_array()
222        .map(|peers| {
223            peers
224                .iter()
225                .filter_map(|p| {
226                    let name = p["name"].as_str()?;
227                    Some(json!({ "name": name, "user_id": p["user_id"].as_str() }))
228                })
229                .collect()
230        })
231        .unwrap_or_default()
232}
233
234// ---------------------------------------------------------------------------------------
235// The *-local/1 Hello first frame
236// ---------------------------------------------------------------------------------------
237
238/// Write the `*-local/N` Hello first frame (the shared handshake convention: every owner-face
239/// server sends `{api, api_version, stack_version}` before anything else).
240pub async fn send_hello<W: AsyncWrite + Unpin>(
241    writer: &mut W,
242    api: &str,
243    api_version: &str,
244    api_minor: u32,
245    stack_version: &str,
246) -> io::Result<()> {
247    let hello = serde_json::to_value(Hello {
248        api: api.into(),
249        api_version: api_version.into(),
250        api_minor,
251        stack_version: stack_version.into(),
252    })
253    .expect("Hello serializes");
254    write_frame(writer, &hello).await
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::codec::{FrameReader, Inbound, MAX_FRAME_BYTES};
261    // Only the unix-gated `register_service` stub below uses these Hello constants.
262    #[cfg(unix)]
263    use crate::protocol::{API_NAME, API_VERSION};
264    use serde_json::json;
265
266    #[test]
267    fn peer_audiences_is_groups_union_eid_union_user_id() {
268        // The platform injection carries the stable device principal `eid:` alongside the
269        // display `name` — and the nickname is NEVER an audience (#38).
270        let peer = json!({
271            "name": "bob-laptop",
272            "eid": "eid:0707",
273            "user_id": "b64u:BOB",
274            "groups": ["eng", "ops"]
275        });
276        let mut a = peer_audiences(&peer);
277        a.sort();
278        assert_eq!(a, vec!["b64u:BOB", "eid:0707", "eng", "ops"]);
279        assert!(
280            !a.iter().any(|s| s == "bob-laptop"),
281            "display nickname must never be an audience"
282        );
283        // DEFAULT-DENY: an absent/empty peer yields nothing.
284        assert!(peer_audiences(&json!({})).is_empty());
285        // Empty-string eid/user_id never become audiences, and a name alone grants NOTHING.
286        assert_eq!(
287            peer_audiences(&json!({"name":"bob","eid":"eid:0707","user_id":"","groups":[]})),
288            vec!["eid:0707"]
289        );
290        assert!(
291            peer_audiences(&json!({"name":"bob","eid":"","user_id":"","groups":[]})).is_empty()
292        );
293    }
294
295    #[test]
296    fn people_from_status_extracts_nickname_and_user_id() {
297        let status = json!({"peers":[
298            {"name":"bob","services":["kb"],"user_id":"b64u:CGnYVhFY"},
299            {"name":"carol","services":[]}
300        ]});
301        assert_eq!(
302            people_from_status(&status),
303            vec![
304                json!({"name":"bob","user_id":"b64u:CGnYVhFY"}),
305                json!({"name":"carol","user_id":null}),
306            ]
307        );
308        assert!(people_from_status(&json!({})).is_empty());
309    }
310
311    #[test]
312    fn internal_error_does_not_echo_detail() {
313        // A retriever IO error may embed a hashed audience-dir path — it must NOT reach a peer.
314        let (code, msg) =
315            internal("open /home/me/.local/share/kb/index/abc123def456/notes.jsonl: No such file");
316        assert_eq!(code, ERR_INTERNAL);
317        assert!(
318            !msg.contains("/home/me"),
319            "no filesystem path in the caller-visible message"
320        );
321        assert!(
322            !msg.contains("index/"),
323            "no index dir in the caller-visible message"
324        );
325        assert_eq!(msg, "internal error");
326    }
327
328    #[test]
329    fn required_string_array_is_strict() {
330        // Present + array of strings → the values.
331        let ok_p = json!({"audiences": ["b64u:BOB", "eng"]});
332        assert_eq!(
333            required_string_array(&ok_p, "audiences").unwrap(),
334            vec!["b64u:BOB".to_string(), "eng".to_string()]
335        );
336        // Empty array is a VALID explicit "share with nobody".
337        assert_eq!(
338            required_string_array(&json!({"audiences": []}), "audiences").unwrap(),
339            Vec::<String>::new()
340        );
341        // Missing key, wrong type, or a non-string element → ERR_PARAMS (never an implicit []).
342        for bad in [
343            json!({}),
344            json!({"audiences": "eng"}),
345            json!({"audiences": 42}),
346            json!({"audiences": ["eng", 7]}),
347            json!({"audiences": null}),
348        ] {
349            let e = required_string_array(&bad, "audiences").unwrap_err();
350            assert_eq!(e.0, ERR_PARAMS, "payload {bad} must be a params error");
351        }
352    }
353
354    #[test]
355    fn ok_err_and_reply_shape_json_rpc_frames() {
356        let o = ok(Some(json!(1)), json!({"x": true}));
357        assert_eq!(o["id"], 1);
358        assert_eq!(o["result"]["x"], true);
359        let e = err(None, ERR_PARAMS, "bad");
360        assert_eq!(e["id"], Value::Null);
361        assert_eq!(e["error"]["code"], ERR_PARAMS);
362        let r = reply(json!(7), Err((ERR_INTERNAL, "internal error".into())));
363        assert_eq!(r["error"]["code"], ERR_INTERNAL);
364        assert_eq!(
365            reply(json!(8), Ok(json!({"ok":true})))["result"]["ok"],
366            true
367        );
368    }
369
370    // Unix-only: exercises the UDS hardening rule (0600/0700 bits, symlink refusal,
371    // peer-euid gate). The windows transport's equivalent guarantee is the owner-only
372    // DACL, covered by tests in `transport::windows`.
373    #[cfg(unix)]
374    #[tokio::test]
375    async fn bind_uds_forces_0600_socket_and_0700_parent() {
376        use std::os::unix::fs::PermissionsExt;
377        let dir = tempfile::tempdir().unwrap();
378        let run = dir.path().join("plug");
379        // Pre-create the runtime dir LAX (0755) — bind_uds must tighten it (loc-L6).
380        std::fs::create_dir_all(&run).unwrap();
381        std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap();
382        let sock = run.join("plug.sock");
383        let _listener = bind_uds(&sock).unwrap();
384        let dir_mode = std::fs::metadata(&run).unwrap().permissions().mode() & 0o777;
385        assert_eq!(dir_mode, 0o700, "runtime dir forced private");
386        let sock_mode = std::fs::metadata(&sock).unwrap().permissions().mode() & 0o777;
387        assert_eq!(sock_mode, 0o600, "socket is owner-only");
388        // Re-bind over a stale socket file succeeds (crash recovery).
389        drop(_listener);
390        let _again = bind_uds(&sock).unwrap();
391    }
392
393    /// Hardening parity: a SYMLINKED runtime dir is refused before any chmod/bind — a
394    /// planted `link -> dir` must never redirect the socket (same rule as the
395    /// daemon control socket).
396    #[cfg(unix)]
397    #[tokio::test]
398    async fn bind_uds_refuses_a_symlinked_runtime_dir() {
399        let dir = tempfile::tempdir().unwrap();
400        let real = dir.path().join("real");
401        std::fs::create_dir_all(&real).unwrap();
402        let link = dir.path().join("link");
403        std::os::unix::fs::symlink(&real, &link).unwrap();
404        let err = bind_uds(&link.join("plug.sock")).unwrap_err();
405        assert!(
406            err.to_string().contains("symlink"),
407            "refusal names the symlink: {err}"
408        );
409        // And ensure_private_dir itself refuses directly too.
410        assert!(ensure_private_dir(&link).is_err());
411        // The real dir still binds fine (the check refuses links, not dirs).
412        let _ok = bind_uds(&real.join("plug.sock")).unwrap();
413    }
414
415    #[cfg(unix)]
416    #[tokio::test]
417    async fn check_peer_uid_accepts_a_same_uid_peer() {
418        let dir = tempfile::tempdir().unwrap();
419        let sock = dir.path().join("uid.sock");
420        let listener = bind_uds(&sock).unwrap();
421        let client = UnixStream::connect(&sock).await.unwrap();
422        let (server, _) = listener.accept().await.unwrap();
423        // Both ends of a same-process connection are, by construction, the same uid.
424        assert!(check_peer_uid(&server));
425        assert!(check_peer_uid(&client));
426    }
427
428    #[tokio::test]
429    async fn send_hello_writes_the_family_hello_frame() {
430        let (mut a, b) = tokio::io::duplex(1024);
431        send_hello(&mut a, "loc-local/1", "1", 0, "0.1.0")
432            .await
433            .unwrap();
434        drop(a);
435        let mut reader = FrameReader::new(b, MAX_FRAME_BYTES);
436        let frame = match reader.next().await.unwrap().unwrap() {
437            Inbound::Frame(v) => v,
438            Inbound::Violation(v) => panic!("violation: {v:?}"),
439        };
440        assert_eq!(frame["api"], "loc-local/1");
441        assert_eq!(frame["api_version"], "1");
442        assert_eq!(frame["stack_version"], "0.1.0");
443    }
444
445    /// A stub mcpmesh daemon that answers one `register_service`, asserting the wire shape.
446    /// Unix-only for now: the stub daemon binds a raw `UnixListener`; porting it to the
447    /// transport seam would let it run on windows too.
448    #[cfg(unix)]
449    #[tokio::test]
450    async fn register_service_registers_a_socket_backend_with_empty_allow() {
451        let dir = tempfile::tempdir().unwrap();
452        let control = dir.path().join("mcpmesh.sock");
453        let listener = tokio::net::UnixListener::bind(&control).unwrap();
454        let server = tokio::spawn(async move {
455            let (stream, _) = listener.accept().await.unwrap();
456            let (read_half, mut writer) = stream.into_split();
457            write_frame(
458                &mut writer,
459                &serde_json::to_value(Hello {
460                    api: API_NAME.into(),
461                    api_version: API_VERSION.into(),
462                    api_minor: 0,
463                    stack_version: "0.1.0".into(),
464                })
465                .unwrap(),
466            )
467            .await
468            .unwrap();
469            let mut reader = FrameReader::new(read_half, MAX_FRAME_BYTES);
470            let req = match reader.next().await.unwrap().unwrap() {
471                Inbound::Frame(v) => v,
472                Inbound::Violation(_) => panic!("violation"),
473            };
474            assert_eq!(req["method"], "register_service");
475            assert_eq!(req["params"]["name"], "loc");
476            assert_eq!(
477                req["params"]["backend"]["socket"]["path"],
478                "/run/x/loc/loc.sock"
479            );
480            assert_eq!(req["params"]["allow"], json!([]));
481            write_frame(
482                &mut writer,
483                &json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}}),
484            )
485            .await
486            .unwrap();
487        });
488        register_service(&control, "loc", Path::new("/run/x/loc/loc.sock"))
489            .await
490            .unwrap();
491        server.await.unwrap();
492
493        // And the failure path returns Err (after logging) instead of swallowing (loc-L2).
494        let gone = dir.path().join("nobody-home.sock");
495        assert!(
496            register_service(&gone, "loc", Path::new("/x"))
497                .await
498                .is_err()
499        );
500    }
501}