mcpmesh-node 0.19.3

Embed a full mcpmesh node in-process — the daemon core as a library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Session backends: the two ways the daemon answers a selected
//! service. Each implements `mcpmesh_net::SessionBackend`, so `mcpmesh_net::serve`
//! hands it the stripped `initialize` frame and the by-value transport, and the
//! backend owns the session's teardown.
//!
//! * [`spawn`] — the `run` backend: fork one child MCP server per session, pump
//!   its stdio to/from the transport, inject the resolved identity as env vars.
//! * [`socket`] — the `socket` backend: dial a long-running local MCP server per
//!   session and inject the resolved identity into the forwarded `initialize`
//!   `_meta["mcpmesh/peer"]` (authoritative — overwrites, never merges).
//!
//! Both backends drive the same session shape once their local MCP server's byte
//! stream exists: forward the `initialize`, then pump frames both directions over
//! `pump` with one codec ([`mcpmesh_net::framing`]) on the server side too. The only
//! thing that differs is HOW the server stream is obtained (fork+stdio vs. dial+UDS)
//! and HOW identity is injected (env vars vs. `_meta`).
//!
//! Fidelity is Value/semantic, not byte-for-byte: every
//! frame round-trips through `serde_json::Value` (object keys re-sorted, no
//! `arbitrary_precision`) — the same caveat as the mesh transport. The platform
//! pumps and never INTERPRETS the MCP method/result semantics; it does re-serialize
//! the JSON.
use anyhow::{Context, Result};
use mcpmesh_net::errors::synthesized_limited;
use mcpmesh_net::framing::{FrameReader, Inbound, write_frame};
use mcpmesh_net::transport::NdjsonTransport;
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite, BufReader};

pub mod socket;
pub mod spawn;

/// Per-session frame cap for the local MCP server's output (16 MiB) — the same
/// bound `mcpmesh_net::serve` applies to the mesh transport.
pub(crate) const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;

// NOTE: the per-service spawn cap is fully HANDLED inside
// `SpawnBackend::run_over` — on cap it synthesizes `-32053` on the transport and returns
// `Ok(())` (a clean refusal, not a session error). There is deliberately no returned error
// type: `run_session` lives in `mcpmesh-net`, which cannot depend on a cli error type (a real
// layering constraint), so a "downcast in the daemon" was never possible. The backend owning
// the refusal is the correct seam.

/// Bidirectionally pump one session between the mesh transport and a local MCP
/// server's byte stream (a spawned child's stdio, or a dialed UDS). Shared by both
/// backends (DRY): only the server-side reader/writer types differ, so this is
/// generic over all four byte substrates.
///
/// The (already identity-augmented) `initialize` is the server's first inbound line,
/// then frames flow both ways — one codec: `write_frame`/`FrameReader` on the server
/// side too, exactly as on the mesh side.
///
/// **The two directions run CONCURRENTLY as independent loops**, not as a single
/// `select!` whose write is awaited inside the arm. That matters for correctness, not
/// just throughput: with a single loop, awaiting a blocked write in one direction
/// (e.g. the server's input pipe is full because the peer is not draining the
/// server's output) would prevent reading the other direction — a classic full-duplex
/// pipe deadlock, reachable under 16 MiB frames. Running the directions
/// concurrently means direction B keeps draining the server's output (unblocking a
/// full pipe) while direction A is blocked writing, so neither side can wedge. A
/// wedge would be doubly bad: `run_over` would never return, so the spawn backend's
/// `kill_on_drop` would never fire AND its owned concurrency permit would leak.
///
/// Whichever direction ends first (EOF, IO error, or a framing violation) tears the
/// session down: the `select!` returns, this fn returns, the caller drops the server
/// connection (killing a child), and `transport.shutdown()` flushes any final frame.
/// `FrameReader::next` and `recv_value`/`send_value` are cancellation-safe, so the
/// cancelled direction drops no committed bytes (a half-written frame on teardown is
/// fine — the session is closing).
///
/// A trusted local server that emits a malformed line is a bug, not an attack: the
/// session ends rather than interpreting it (the platform pumps, never interprets).
pub(crate) async fn pump<TR, TW, SR, SW>(
    initialize: Value,
    transport: &mut NdjsonTransport<TR, TW>,
    server_read: SR,
    mut server_write: SW,
    auditor: crate::audit::RequestAuditor,
    rate: crate::limits::RateGate,
) -> Result<()>
where
    TR: AsyncRead + Send + Unpin,
    TW: AsyncWrite + Send + Unpin,
    SR: AsyncRead + Send + Unpin,
    SW: AsyncWrite + Send + Unpin,
{
    write_frame(&mut server_write, &initialize)
        .await
        .context("forward initialize to local MCP server")?;
    // Wrapped so Direction A can DROP the write half at end-of-input: for a spawned child,
    // `AsyncWriteExt::shutdown` on its stdin only flushes — the fd closes (and the child sees
    // EOF) on drop; for a socket backend the drop sends the FIN just as shutdown would.
    let mut server_write = Some(server_write);

    // The outbound direction sends through a cloned writer handle so it does not need
    // `&mut transport` (which the inbound direction holds for `recv_value`). This
    // disjoint split — reader on one side, the Arc'd writer on the other — is what
    // lets the two loops run concurrently without a shared mutable borrow.
    // A second cloned writer so Direction A can send a -32053 throttle reply without borrowing the
    // transport Direction B holds (both send through the same Arc<Mutex> write half — safe).
    let throttle_writer = transport.writer();
    let transport_writer = transport.writer();
    let mut server_out = FrameReader::new(BufReader::new(server_read), MAX_FRAME_BYTES);

    // Direction A: mesh transport → local server. Owns `&mut transport` (recv) and
    // `server_write`; ends on transport EOF/error or the server's input closing.
    let to_server = async {
        loop {
            match transport.recv_value().await {
                Ok(Some(frame)) => {
                    // Per-identity rate limit: consult BEFORE forwarding a
                    // proxied REQUEST/notification (a method-bearing frame). FAIL-SAFE over-limit —
                    // DROP the request (never forward, never queue), reply -32053{retry_after_ms}
                    // for a request id (silent-drop a notification), and CONTINUE the session
                    // (bounded backpressure, not a close).
                    if frame.get("method").is_some()
                        && let Err(retry_after_ms) = rate.admit()
                    {
                        if let Some(id) = frame.get("id").filter(|v| !v.is_null()).cloned() {
                            let _ = throttle_writer
                                .send_value(synthesized_limited(id, retry_after_ms))
                                .await;
                        }
                        continue;
                    }
                    // Proxied-request-line audit hook: hash args + record method/tool BEFORE
                    // forwarding. PRIVACY — sees raw args (the server needs them); stores only blake3.
                    auditor.on_request(&frame);
                    let Some(w) = server_write.as_mut() else {
                        break;
                    };
                    if write_frame(w, &frame).await.is_err() {
                        break; // server input closed — server is gone
                    }
                }
                Ok(None) => break, // transport EOF / clean close
                Err(_) => break,   // transport IO error or framing violation
            }
        }
        // The peer half-closed (no more requests) or the transport failed — either way this
        // direction is done, but the SESSION is not: responses to already-forwarded requests
        // may still be inside the server. Close the server's stdin so it sees end-of-input
        // and can finish, then park: only Direction B draining to the server's output EOF may
        // end the session. Winning the select! here would cancel B and drop those replies —
        // the one-shot client (`printf ... | mcpmesh connect ...`) hits exactly that race.
        if let Some(mut w) = server_write.take() {
            use tokio::io::AsyncWriteExt;
            let _ = w.shutdown().await;
        } // dropped: the child's stdin fd closes / the socket FINs — the backend sees EOF
        std::future::pending::<()>().await
    };

    // Direction B: local server → mesh transport. Owns the FrameReader and the cloned
    // writer handle; ends on the server's output EOF/error/violation or a gone peer.
    let to_transport = async {
        loop {
            match server_out.next().await {
                Ok(Some(Inbound::Frame(frame))) => {
                    // Response correlation: count the bytes going OUT to the peer (a
                    // COUNT, never the content) and let the auditor emit the completed request record.
                    let bytes_out = serde_json::to_vec(&frame)
                        .map(|v| v.len() as u64)
                        .unwrap_or(0);
                    auditor.on_response(&frame, bytes_out);
                    if transport_writer.send_value(frame).await.is_err() {
                        break; // peer is gone
                    }
                }
                Ok(Some(Inbound::Violation(_))) => break,
                Ok(None) => break, // server output EOF — server closed the session
                Err(_) => break,   // IO error reading the server
            }
        }
    };

    // Direction A parks after end-of-input instead of finishing, so B — the drain toward
    // the peer — is the only branch that can end the session (on the server's output EOF).
    tokio::select! {
        () = to_server => {}
        () = to_transport => {}
    }

    // Flush any final buffered frame (e.g. a last reply) before the write half
    // closes; a no-op once already closed.
    let _ = transport.shutdown().await;
    Ok(())
}

#[cfg(test)]
mod tests {
    //! Pins the pump's TEARDOWN DISCIPLINE (issue #25): transport EOF ends only the
    //! REQUEST direction — Direction A closes the server's stdin and PARKS, and the
    //! session ends solely when Direction B drains the server's output to EOF. The
    //! pre-fix `select!` let Direction A's completion cancel B, dropping every reply
    //! still inside the server — exactly what a one-shot client (request, then
    //! immediate EOF) provokes. In-memory duplex on all four substrates (the
    //! `proxy::pump_stdio` test pattern); the fake server deliberately withholds its
    //! replies until it sees end-of-input, so a pump that tears down on transport EOF
    //! can never pass.
    //!
    //! Plumbing invariant: each direction is a WHOLE `DuplexStream` used one-way (the
    //! other way stays idle), never a `tokio::io::split` half — dropping a `WriteHalf`
    //! does NOT drop the underlying stream (its `ReadHalf` keeps it alive), so a
    //! split-based harness never delivers the EOFs this test is about.
    use std::time::Duration;

    use serde_json::json;
    use tokio::io::duplex;
    use tokio::time::timeout;

    use super::*;
    use crate::audit::{AuditSink, RequestAuditor};
    use crate::limits::{RateGate, RateLimiter};

    /// #91: a server-initiated NOTIFICATION reaches the peer, and is NOT metered.
    ///
    /// Direction B forwards every frame the local server writes, including method-bearing ones,
    /// with no limiter consult — which is what makes "an agent reacts to an incoming message"
    /// possible rather than requiring the peer to poll. That behaviour was emergent and untested;
    /// #45's stateless rework removes the `initialize` handshake this session shape is built
    /// around, and a property nobody wrote down is a property nobody notices removing.
    ///
    /// The rate gate here is set to a budget of ONE and then exhausted by the inbound request, so
    /// an implementation that metered Direction B against the same per-identity budget would drop
    /// or -32053 this notification instead of forwarding it.
    #[tokio::test]
    async fn a_server_initiated_notification_reaches_the_peer_unmetered() {
        timeout(Duration::from_secs(10), async {
            let (mut peer_w, tr) = duplex(64 * 1024);
            let (tw, peer_r) = duplex(64 * 1024);
            let mut transport = NdjsonTransport::new(tr, tw, MAX_FRAME_BYTES);
            let (server_write, srv_stdin) = duplex(64 * 1024);
            let (srv_stdout, server_read) = duplex(64 * 1024);

            // The server answers the request AND pushes an unsolicited notification afterwards.
            let server = tokio::spawn(async move {
                let mut srv_w = srv_stdout;
                let mut reader = FrameReader::new(srv_stdin, MAX_FRAME_BYTES);
                let mut seen = 0usize;
                while let Ok(Some(Inbound::Frame(f))) = reader.next().await {
                    seen += 1;
                    if f["method"] == "tools/call" {
                        write_frame(&mut srv_w, &json!({"jsonrpc":"2.0","id":f["id"]}))
                            .await
                            .unwrap();
                        // UNSOLICITED: no id, a method, nobody asked for it.
                        write_frame(
                            &mut srv_w,
                            &json!({"jsonrpc":"2.0","method":"notifications/message",
                                    "params":{"level":"info","data":"pushed"}}),
                        )
                        .await
                        .unwrap();
                    }
                }
                seen
            });

            // Budget of ONE request per minute — consumed by `tools/call` below.
            // Some(endpoint), NOT None: RateGate::admit_at returns Ok unconditionally for a
            // None identity, so a None gate meters NOTHING and cannot distinguish the property
            // under test from an absent limiter. The first version of this test used None and was
            // therefore vacuous — metering Direction B did not break it.
            let rate = RateGate::new(
                std::sync::Arc::new(crate::limits::RateLimiter::per_minute(1, 1)),
                Some(mcpmesh_net::EndpointId::from_bytes([9u8; 32])),
            );
            let pump_task = tokio::spawn(async move {
                pump(
                    json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}),
                    &mut transport,
                    server_read,
                    server_write,
                    RequestAuditor::new(AuditSink::disabled(), Some("bob".into()), "echo".into()),
                    rate,
                )
                .await
            });

            write_frame(
                &mut peer_w,
                &json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}),
            )
            .await
            .unwrap();
            // A SECOND metered request makes the exhaustion OBSERVABLE rather than assumed.
            write_frame(
                &mut peer_w,
                &json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{}}),
            )
            .await
            .unwrap();

            let mut peer_reader = FrameReader::new(peer_r, MAX_FRAME_BYTES);
            let mut saw_limited = false;
            let reply = loop {
                match peer_reader.next().await.unwrap() {
                    Some(Inbound::Frame(f)) => {
                        if f["error"]["code"] == -32053 {
                            saw_limited = true;
                            continue;
                        }
                        if f["id"] == 2 {
                            break f;
                        }
                    }
                    other => panic!("expected the id=2 reply, got {other:?}"),
                }
            };
            assert_eq!(reply["id"], 2, "the solicited reply arrives");
            assert!(
                saw_limited,
                "the per-identity budget must be EXHAUSTED by now — without that this test cannot \
                 distinguish 'Direction B is unmetered' from 'there was budget left'"
            );

            let pushed = match peer_reader.next().await.unwrap() {
                Some(Inbound::Frame(f)) => f,
                other => panic!("expected the server-initiated notification, got {other:?}"),
            };
            assert_eq!(
                pushed["method"], "notifications/message",
                "an unsolicited server notification must reach the peer — this is what makes push \
                 possible instead of polling (#91): {pushed}"
            );
            assert!(
                pushed.get("id").is_none_or(|v| v.is_null()),
                "and it is a notification, not a request: {pushed}"
            );

            drop(peer_w);
            let _ = server.await;
            let _ = pump_task.await;
        })
        .await
        .expect("server-initiated notification test timed out");
    }

    /// The client sends `initialize` + one request and then EOFs the transport; the
    /// server replies to BOTH only after seeing its stdin close. Both replies must
    /// still reach the peer (the old select!-cancel dropped them) and `pump` must
    /// return Ok. Looped because the pre-fix loss was a scheduling coin flip
    /// (`select!` polls branches in random order).
    #[tokio::test]
    async fn transport_eof_does_not_drop_replies_still_inside_the_server() {
        timeout(Duration::from_secs(10), async {
            for _ in 0..25 {
                // Mesh transport, one whole DuplexStream per direction: peer→pump
                // (dropping `peer_w` = the peer's EOF) and pump→peer.
                let (mut peer_w, tr) = duplex(64 * 1024);
                let (tw, peer_r) = duplex(64 * 1024);
                let mut transport = NdjsonTransport::new(tr, tw, MAX_FRAME_BYTES);
                // The server's stdio, likewise: pump→server stdin, server stdout→pump.
                let (server_write, srv_stdin) = duplex(64 * 1024);
                let (srv_stdout, server_read) = duplex(64 * 1024);

                // The fake server: collect every inbound frame, reply ONLY after stdin
                // EOF (so any teardown racing the drain is caught), then close stdout.
                let server = tokio::spawn(async move {
                    let mut srv_w = srv_stdout;
                    let mut reader = FrameReader::new(srv_stdin, MAX_FRAME_BYTES);
                    let mut seen = Vec::new();
                    while let Ok(Some(Inbound::Frame(f))) = reader.next().await {
                        seen.push(f);
                    }
                    // stdin EOF'd — the pump's Direction A closed it. Now echo each
                    // frame back as its "reply" and close stdout (session end).
                    for f in &seen {
                        write_frame(&mut srv_w, &json!({"jsonrpc": "2.0", "id": f["id"]}))
                            .await
                            .unwrap();
                    }
                    seen.len()
                });

                let pump_task = tokio::spawn(async move {
                    pump(
                        json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}),
                        &mut transport,
                        server_read,
                        server_write,
                        RequestAuditor::new(
                            AuditSink::disabled(),
                            Some("bob".into()),
                            "echo".into(),
                        ),
                        RateGate::new(RateLimiter::unlimited_shared(), None),
                    )
                    .await
                });

                // The one-shot client shape: one request behind the initialize, then EOF.
                write_frame(
                    &mut peer_w,
                    &json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}),
                )
                .await
                .unwrap();
                drop(peer_w);

                // BOTH replies drain back to the peer, in order, then a clean EOF.
                let mut peer_reader = FrameReader::new(peer_r, MAX_FRAME_BYTES);
                for expect_id in [1, 2] {
                    match peer_reader.next().await.unwrap() {
                        Some(Inbound::Frame(f)) => assert_eq!(
                            f["id"], expect_id,
                            "the reply to request {expect_id} must survive transport EOF: {f}"
                        ),
                        other => panic!("expected the id={expect_id} reply, got {other:?}"),
                    }
                }
                assert!(
                    peer_reader.next().await.unwrap().is_none(),
                    "after the server's output EOF the session closes cleanly"
                );
                assert_eq!(
                    server.await.unwrap(),
                    2,
                    "the server saw initialize + request"
                );
                pump_task.await.unwrap().expect("pump returns Ok");
            }
        })
        .await
        .expect("pump drain test timed out");
    }
}