Skip to main content

mcp/
server.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The reusable **MCP server** base: transport, framing, connection handling, the
3//! lifecycle/version machinery, and the resource-subscription registry — agentd's
4//! served self-MCP (and any other embedder's server) builds its domain surface on
5//! top by implementing [`Handler`].
6//!
7//! The split mirrors the client: this module owns the *protocol* (how bytes become
8//! requests, how `initialize` / `server/discover` / `ping` are answered across
9//! both eras, how a subscriber is pushed a `notifications/resources/updated`),
10//! while the embedder owns the *domain* (which tools exist, which resources are
11//! readable, who may subscribe to what). One [`Handler`] trait is the seam.
12//!
13//! Transport is deliberately minimal and dependency-light (RFC 0015 §3.6): a
14//! blocking listener, one thread per connection, speaking the same NDJSON JSON-RPC
15//! codec ([`crate::rpc::frame`]) as the client. No async, no mio. [`ServeStream`]
16//! type-erases unix vs. vsock so the framing, threading, and dispatch are entirely
17//! transport-agnostic ("the unix server with the socket type swapped").
18
19use crate::rpc::{Incoming, Notification, Request, Response, frame};
20use crate::wire::method;
21use serde_json::{Value, json};
22use std::collections::HashMap;
23use std::io::{self, BufReader, Read, Write};
24use std::os::unix::net::{UnixListener, UnixStream};
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex};
27use std::thread;
28use std::time::Duration;
29
30/// Which transport a connection arrived on, and therefore its trust domain (RFC
31/// 0015 §3.3-§3.4). A generic two-domain model the framework only carries and
32/// hands to the [`Handler`]; the embedder assigns meaning:
33///   * [`Stdio`](PeerOrigin::Stdio) — an in-process / same-trust caller (agentd's
34///     own driving harness over the process stdio).
35///   * [`Management`](PeerOrigin::Management) — a peer that dialed a listener (unix
36///     socket / vsock), i.e. the management trust domain.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PeerOrigin {
39    /// The process's own stdio / an in-process caller (the driving harness).
40    Stdio,
41    /// A peer on a listener (unix / vsock) — the management trust domain.
42    Management,
43}
44
45impl PeerOrigin {
46    /// Stable lowercase label for logs/metrics.
47    pub fn as_str(self) -> &'static str {
48        match self {
49            PeerOrigin::Stdio => "stdio",
50            PeerOrigin::Management => "management",
51        }
52    }
53}
54
55/// The served-MCP transport, type-erased to one concrete enum so the connection
56/// registry ([`SharedWriter`], [`Subscriber`]) stays monomorphic across transports
57/// while the *same* connection code serves each. The socket variants are
58/// `Read + Write` with a [`try_clone`](ServeStream::try_clone) (their write half is
59/// shared with the threads that push NDJSON notifications). The [`Http`](ServeStream::Http)
60/// variant is a **write-only SSE sink**: an HTTP subscription stream's write half,
61/// so an HTTP subscriber registers in the SAME [`SubRegistry`] and the embedder's
62/// existing `notify_*` calls reach it transparently — the framing (NDJSON vs SSE
63/// `data:` events) is chosen per-variant in [`write_notification`](ServeStream::write_notification).
64pub enum ServeStream {
65    /// A unix-domain-socket peer.
66    Unix(UnixStream),
67    /// An AF_VSOCK peer (host↔guest management transport).
68    #[cfg(feature = "vsock")]
69    Vsock(vsock::VsockStream),
70    /// The write half of an HTTP subscription (SSE) stream — a push-only sink.
71    /// Never read from, never a reply channel; notifications are framed as SSE
72    /// `data:` events. Boxed so it spans plain TCP and (feature `tls`) TLS.
73    Http(Box<dyn Write + Send>),
74}
75
76impl ServeStream {
77    /// Clone the handle (a second fd onto the same connection) for the shared write
78    /// half. Mirrors `UnixStream::try_clone`. The [`Http`](ServeStream::Http) sink
79    /// is single-owner (its SharedWriter is built directly from the stream half),
80    /// so it is never cloned — attempting to is an error.
81    pub fn try_clone(&self) -> io::Result<ServeStream> {
82        match self {
83            ServeStream::Unix(s) => s.try_clone().map(ServeStream::Unix),
84            #[cfg(feature = "vsock")]
85            ServeStream::Vsock(s) => s.try_clone().map(ServeStream::Vsock),
86            ServeStream::Http(_) => Err(io::Error::new(
87                io::ErrorKind::Unsupported,
88                "http SSE sink is not clonable",
89            )),
90        }
91    }
92
93    /// Bound a stalled-but-alive peer so it can't pin the writer Mutex forever.
94    /// The HTTP sink sets its timeout on the underlying stream before boxing.
95    pub fn set_write_timeout(&self, dur: Option<Duration>) -> io::Result<()> {
96        match self {
97            ServeStream::Unix(s) => s.set_write_timeout(dur),
98            #[cfg(feature = "vsock")]
99            ServeStream::Vsock(s) => s.set_write_timeout(dur),
100            ServeStream::Http(_) => Ok(()),
101        }
102    }
103
104    /// Push one notification, framed for this transport: NDJSON (one JSON object +
105    /// `\n`) on the socket variants, an SSE `data:` event on [`Http`](ServeStream::Http).
106    /// This is the single seam the `notify_*` push helpers write through.
107    pub fn write_notification(&mut self, note: &Notification) -> io::Result<()> {
108        if let ServeStream::Http(sink) = self {
109            let json = serde_json::to_string(note).map_err(io::Error::other)?;
110            sink.write_all(format!("data: {json}\n\n").as_bytes())?;
111            return sink.flush();
112        }
113        frame::write_line(self, note)
114    }
115
116    /// Write one JSON-RPC RESPONSE frame with per-transport framing — the
117    /// server-streaming twin of [`write_notification`]: an HTTP (SSE) sink gets
118    /// a `data:` event, a socket peer gets an NDJSON line. Streaming method
119    /// handlers (A2A `StreamResponse` frames) push through this so one handler
120    /// serves every transport.
121    pub fn write_response(&mut self, resp: &Response) -> io::Result<()> {
122        if let ServeStream::Http(sink) = self {
123            let json = serde_json::to_string(resp).map_err(io::Error::other)?;
124            sink.write_all(format!("data: {json}\n\n").as_bytes())?;
125            return sink.flush();
126        }
127        frame::write_line(self, resp)
128    }
129}
130
131impl Read for ServeStream {
132    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
133        match self {
134            ServeStream::Unix(s) => s.read(buf),
135            #[cfg(feature = "vsock")]
136            ServeStream::Vsock(s) => s.read(buf),
137            // A push-only sink; a reader loop never runs over it.
138            ServeStream::Http(_) => Ok(0),
139        }
140    }
141}
142
143impl Write for ServeStream {
144    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
145        match self {
146            ServeStream::Unix(s) => s.write(buf),
147            #[cfg(feature = "vsock")]
148            ServeStream::Vsock(s) => s.write(buf),
149            ServeStream::Http(s) => s.write(buf),
150        }
151    }
152    fn flush(&mut self) -> io::Result<()> {
153        match self {
154            ServeStream::Unix(s) => s.flush(),
155            #[cfg(feature = "vsock")]
156            ServeStream::Vsock(s) => s.flush(),
157            ServeStream::Http(s) => s.flush(),
158        }
159    }
160}
161
162/// A connection's shared write half — both replies and pushed notifications go
163/// through it, serialized by the Mutex (a reply and a notification can't interleave
164/// bytes). The [`ServeStream`] enum keeps this one type across unix + vsock peers.
165pub type SharedWriter = Arc<Mutex<ServeStream>>;
166
167/// A peer subscribed to a resource: which connection, and the writer to push a
168/// `notifications/resources/updated` to. Opaque — fields are private; construct +
169/// mutate a registry through [`register_subscriber`] / [`drop_subscription`] /
170/// [`remove_conn_subscriptions`] and fire pushes through the `notify_*` helpers.
171pub struct Subscriber {
172    conn: u64,
173    writer: SharedWriter,
174}
175
176/// `uri` → its subscribers. Pushed when a resource changes. `Arc`-shared with the
177/// background threads that mutate resource state (a run reaching a terminal status,
178/// a reload landing, an event-ring growth).
179pub type SubRegistry = Arc<Mutex<HashMap<String, Vec<Subscriber>>>>;
180
181/// Register `conn` (with its `writer`) as a subscriber of `uri`, idempotently — a
182/// second subscribe from the same connection is a no-op rather than a duplicate
183/// push target. The embedder does its own gating (which URIs are subscribable, who
184/// may subscribe) *before* calling this.
185pub fn register_subscriber(subs: &SubRegistry, uri: &str, conn: u64, writer: &SharedWriter) {
186    let mut g = subs.lock().unwrap_or_else(|e| e.into_inner());
187    let list = g.entry(uri.to_string()).or_default();
188    if !list.iter().any(|s| s.conn == conn) {
189        list.push(Subscriber {
190            conn,
191            writer: Arc::clone(writer),
192        });
193    }
194}
195
196/// Drop `conn`'s subscription to a single `uri` (the `resources/unsubscribe` path).
197/// Prunes the uri entry entirely once its last subscriber leaves.
198pub fn drop_subscription(subs: &SubRegistry, uri: &str, conn: u64) {
199    let mut g = subs.lock().unwrap_or_else(|e| e.into_inner());
200    if let Some(list) = g.get_mut(uri) {
201        list.retain(|s| s.conn != conn);
202        if list.is_empty() {
203            g.remove(uri);
204        }
205    }
206}
207
208/// Drop every subscription held by a (now-closed) connection — called when a
209/// connection's reader loop ends so pushes never target a dead socket.
210pub fn remove_conn_subscriptions(subs: &SubRegistry, conn: u64) {
211    let mut g = subs.lock().unwrap_or_else(|e| e.into_inner());
212    g.retain(|_uri, list| {
213        list.retain(|s| s.conn != conn);
214        !list.is_empty()
215    });
216}
217
218/// Push `notifications/resources/updated{uri}` to every current subscriber of
219/// `uri`, **consuming** the subscription list (the resource changes exactly once —
220/// e.g. a subagent run reaching its terminal status — so no entry should linger
221/// after its one event). Best-effort: a write to a dead peer fails and is cleaned
222/// up when that connection's reader loop ends. The lock is released before writing,
223/// so a slow/blocked peer can't stall other notifications.
224pub fn notify_resource_updated(subs: &SubRegistry, uri: &str) {
225    let writers: Vec<SharedWriter> = {
226        let mut g = subs.lock().unwrap_or_else(|e| e.into_inner());
227        match g.remove(uri) {
228            Some(list) => list.into_iter().map(|s| s.writer).collect(),
229            None => return,
230        }
231    };
232    push_updated(&writers, uri);
233}
234
235/// Like [`notify_resource_updated`] but **keeps** the subscriber list — for
236/// resources that change REPEATEDLY (a run aggregate on each spawn, a warm session
237/// on each turn boundary, `config/effective` on each reload, an event ring on each
238/// batch). Cloning the writers under the lock (then releasing it before writing)
239/// keeps the entry intact for the next emission. Dead peers are pruned when their
240/// reader loop ends ([`remove_conn_subscriptions`]).
241pub fn notify_resource_updated_keep(subs: &SubRegistry, uri: &str) {
242    let writers: Vec<SharedWriter> = {
243        let g = subs.lock().unwrap_or_else(|e| e.into_inner());
244        match g.get(uri) {
245            Some(list) => list.iter().map(|s| Arc::clone(&s.writer)).collect(),
246            None => return,
247        }
248    };
249    push_updated(&writers, uri);
250}
251
252fn push_updated(writers: &[SharedWriter], uri: &str) {
253    let note = Notification::new(
254        method::NOTIFY_RESOURCES_UPDATED,
255        Some(json!({ "uri": uri })),
256    );
257    for w in writers {
258        if let Ok(mut wl) = w.lock() {
259            let _ = wl.write_notification(&note);
260        }
261    }
262}
263
264/// Broadcast a payload-free `note` to every DISTINCT writer currently in the
265/// registry — for connection-scoped notifications that aren't tied to a single uri
266/// (e.g. `notifications/tools/list_changed` after a hot reload changed the tool
267/// set). A connection subscribed to several resources is written to once. Dead
268/// writers are pruned by their own reader loop.
269pub fn broadcast_distinct(subs: &SubRegistry, note: &Notification) {
270    let writers: Vec<SharedWriter> = {
271        let g = subs.lock().unwrap_or_else(|e| e.into_inner());
272        let mut seen: Vec<*const Mutex<ServeStream>> = Vec::new();
273        let mut out: Vec<SharedWriter> = Vec::new();
274        for list in g.values() {
275            for s in list {
276                let ptr = Arc::as_ptr(&s.writer);
277                if !seen.contains(&ptr) {
278                    seen.push(ptr);
279                    out.push(Arc::clone(&s.writer));
280                }
281            }
282        }
283        out
284    };
285    for w in writers {
286        if let Ok(mut wl) = w.lock() {
287            let _ = wl.write_notification(note);
288        }
289    }
290}
291
292// ---------------------------------------------------------------------------
293// The connection framework: the lifecycle/version machinery, the `Handler` seam,
294// and the blocking thread-per-connection listeners. An embedder implements
295// [`Handler`] for its domain surface and calls [`serve_unix`] / [`serve_vsock`];
296// everything below is transport- and domain-agnostic.
297// ---------------------------------------------------------------------------
298
299/// The embedder's domain seam. The framework owns the transport, the framing, the
300/// connection lifecycle, and the subscription registry; the `Handler` supplies the
301/// *meaning* — which tools/resources exist, who may call/read/subscribe to what.
302///
303/// Lifecycle (`initialize` / `server/discover` / `ping`) is NOT routed here: a
304/// handler answers it once, version-aware, by calling [`lifecycle_response`] at the
305/// top of its [`dispatch`](Handler::dispatch) (so the multi-version negotiation
306/// lives in one place). Everything else — `tools/*`, `resources/*`, and any custom
307/// method — flows through `dispatch`.
308pub trait Handler: Send + Sync + 'static {
309    /// Route one request to a response. `origin` is the caller's trust domain;
310    /// `writer`/`conn` identify the connection so a `resources/subscribe` can
311    /// register a push target via [`register_subscriber`]. Called from the
312    /// connection's own thread — implementations do their own locking.
313    fn dispatch(
314        &self,
315        req: Request,
316        origin: PeerOrigin,
317        writer: &SharedWriter,
318        conn: u64,
319    ) -> Response;
320
321    /// Called once when a connection is accepted (before its first request), for
322    /// logging/metrics. Default: nothing.
323    fn on_connect(&self, _origin: PeerOrigin, _conn: u64) {}
324
325    /// Whether `method` responds as a SERVER STREAM (several frames pushed
326    /// through the dispatch `writer`, the returned `Response` being the final
327    /// frame). The HTTP transport upgrades such a request to a `text/event-stream`
328    /// response instead of a unary `application/json` one. Default: nothing
329    /// streams.
330    fn streams(&self, _method: &str) -> bool {
331        false
332    }
333
334    /// Called once when a connection's reader loop ends. The framework has already
335    /// dropped the connection's subscriptions; this is for logging/metrics or any
336    /// embedder-side per-connection cleanup. Default: nothing.
337    fn on_disconnect(&self, _origin: PeerOrigin, _conn: u64) {}
338}
339
340/// Answer the three lifecycle methods every MCP server must handle, in ONE place,
341/// version-aware across both eras — the server-side mirror of the client's version
342/// negotiation. Returns `Some(response)` for `initialize` / `server/discover` /
343/// `ping`, or `None` if `req.method` is a domain method the [`Handler`] must route.
344///
345///   * `initialize` (legacy handshake): negotiate the protocol version — echo the
346///     peer's requested version when it's [supported](crate::version::is_supported_version),
347///     else fall back to our latest legacy [`PROTOCOL_VERSION`](crate::version::PROTOCOL_VERSION).
348///   * `server/discover` (modern, stateless): advertise the full
349///     [`SUPPORTED_PROTOCOL_VERSIONS`](crate::version::SUPPORTED_PROTOCOL_VERSIONS)
350///     list + capabilities in one call, so a modern client needn't fall back to the
351///     legacy handshake. This is what makes the embedder a *dual-era server*.
352///   * `ping`: an empty result.
353///
354/// `server_info` is the `{name, version}` object and `capabilities` the advertised
355/// capability object; both are echoed verbatim into the two lifecycle replies. When
356/// the crate gains support for a new protocol version, both replies pick it up here
357/// without the embedder changing anything.
358pub fn lifecycle_response(
359    req: &Request,
360    server_info: &Value,
361    capabilities: &Value,
362) -> Option<Response> {
363    match req.method.as_str() {
364        "initialize" => {
365            let requested = req
366                .params
367                .as_ref()
368                .and_then(|p| p.get("protocolVersion"))
369                .and_then(Value::as_str);
370            let version = match requested {
371                Some(v) if crate::version::is_supported_version(v) => v,
372                _ => crate::version::PROTOCOL_VERSION,
373            };
374            Some(Response::ok(
375                req.id.clone(),
376                json!({
377                    "protocolVersion": version,
378                    "capabilities": capabilities,
379                    "serverInfo": server_info,
380                }),
381            ))
382        }
383        method::SERVER_DISCOVER => Some(Response::ok(
384            req.id.clone(),
385            json!({
386                "resultType": "complete",
387                "supportedVersions": crate::version::SUPPORTED_PROTOCOL_VERSIONS,
388                "capabilities": capabilities,
389                "serverInfo": server_info,
390            }),
391        )),
392        "ping" => Some(Response::ok(req.id.clone(), json!({}))),
393        _ => None,
394    }
395}
396
397/// Serve one accepted connection to completion: the blocking NDJSON read loop.
398/// Requests get a reply (through the shared writer, which a background thread may
399/// also push notifications on — the Mutex serializes them); notifications
400/// (`initialized`, …) are read and dropped. On EOF/hangup the connection's
401/// subscriptions are dropped so no push ever targets a dead socket. A write timeout
402/// bounds a stalled-but-alive peer so it can't pin the writer Mutex (and a pushing
403/// thread) forever.
404pub fn handle_conn(
405    stream: ServeStream,
406    origin: PeerOrigin,
407    handler: &Arc<dyn Handler>,
408    subs: &SubRegistry,
409    conn_counter: &AtomicU64,
410    write_timeout: Duration,
411) {
412    let writer: SharedWriter = match stream.try_clone() {
413        Ok(w) => {
414            let _ = w.set_write_timeout(Some(write_timeout));
415            Arc::new(Mutex::new(w))
416        }
417        Err(_) => return,
418    };
419    let conn = conn_counter.fetch_add(1, Ordering::Relaxed);
420    handler.on_connect(origin, conn);
421    let mut reader = BufReader::new(stream);
422    while let Ok(Some(bytes)) = frame::read_line(&mut reader) {
423        if let Ok(Incoming::Request(req)) = serde_json::from_slice::<Incoming>(&bytes) {
424            let resp = handler.dispatch(req, origin, &writer, conn);
425            let wrote = writer
426                .lock()
427                .is_ok_and(|mut w| frame::write_line(&mut *w, &resp).is_ok());
428            if !wrote {
429                break; // peer hung up mid-reply
430            }
431        }
432    }
433    remove_conn_subscriptions(subs, conn); // don't push to a dead socket
434    handler.on_disconnect(origin, conn);
435}
436
437/// Bind a unix socket for serving, clearing any stale socket file first. Returned
438/// separately from the accept loop so the caller can log/act on a successful bind
439/// (or propagate the bind error) before the accept thread starts.
440pub fn bind_unix(path: &str) -> io::Result<UnixListener> {
441    // A stale socket from a crashed prior run would block the bind; clear it.
442    let _ = std::fs::remove_file(path);
443    UnixListener::bind(path)
444}
445
446/// Spawn the background accept thread for `listener`: one blocking thread per
447/// connection, each running [`handle_conn`] against `handler`. Peers arrive in the
448/// [`PeerOrigin::Management`] trust domain (they dialed a listener). Returns once
449/// the accept thread is spawned; a thread-spawn failure is surfaced as the error.
450pub fn spawn_accept_unix(
451    listener: UnixListener,
452    handler: Arc<dyn Handler>,
453    subs: SubRegistry,
454    conn_counter: Arc<AtomicU64>,
455    write_timeout: Duration,
456) -> io::Result<()> {
457    thread::Builder::new()
458        .name("serve-mcp".into())
459        .spawn(move || {
460            for stream in listener.incoming().flatten() {
461                let handler = Arc::clone(&handler);
462                let subs = Arc::clone(&subs);
463                let conn_counter = Arc::clone(&conn_counter);
464                thread::Builder::new()
465                    .name("serve-mcp-conn".into())
466                    .spawn(move || {
467                        handle_conn(
468                            ServeStream::Unix(stream),
469                            PeerOrigin::Management,
470                            &handler,
471                            &subs,
472                            &conn_counter,
473                            write_timeout,
474                        )
475                    })
476                    .ok();
477            }
478        })
479        .map(|_| ())
480}
481
482/// Bind an AF_VSOCK `(cid, port)` for serving — the management transport (RFC 0015
483/// §3.2). The vsock counterpart of [`bind_unix`].
484#[cfg(feature = "vsock")]
485pub fn bind_vsock(cid: u32, port: u32) -> io::Result<vsock::VsockListener> {
486    vsock::VsockListener::bind_with_cid_port(cid, port)
487}
488
489/// Spawn the background accept thread for a vsock `listener` — byte-for-byte
490/// [`spawn_accept_unix`] with the socket type swapped (the same [`handle_conn`], no
491/// new framing). Peers arrive in [`PeerOrigin::Management`].
492#[cfg(feature = "vsock")]
493pub fn spawn_accept_vsock(
494    listener: vsock::VsockListener,
495    handler: Arc<dyn Handler>,
496    subs: SubRegistry,
497    conn_counter: Arc<AtomicU64>,
498    write_timeout: Duration,
499) -> io::Result<()> {
500    thread::Builder::new()
501        .name("serve-mcp-vsock".into())
502        .spawn(move || {
503            for stream in listener.incoming().flatten() {
504                let handler = Arc::clone(&handler);
505                let subs = Arc::clone(&subs);
506                let conn_counter = Arc::clone(&conn_counter);
507                thread::Builder::new()
508                    .name("serve-mcp-conn".into())
509                    .spawn(move || {
510                        handle_conn(
511                            ServeStream::Vsock(stream),
512                            PeerOrigin::Management,
513                            &handler,
514                            &subs,
515                            &conn_counter,
516                            write_timeout,
517                        )
518                    })
519                    .ok();
520            }
521        })
522        .map(|_| ())
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::rpc::Request;
529    use std::io::BufReader;
530    use std::os::unix::net::UnixStream;
531
532    // --- lifecycle / version negotiation ---------------------------------
533
534    fn info() -> Value {
535        json!({"name": "test-server", "version": "9.9.9"})
536    }
537    fn caps() -> Value {
538        json!({"tools": {}, "resources": {"subscribe": true}})
539    }
540
541    #[test]
542    fn initialize_echoes_a_supported_requested_version() {
543        // A legacy client requesting a version we support gets it echoed back.
544        let want = crate::version::SUPPORTED_PROTOCOL_VERSIONS[1]; // a non-latest supported one
545        let req = Request::new(1, "initialize", Some(json!({"protocolVersion": want})));
546        let resp = lifecycle_response(&req, &info(), &caps()).expect("lifecycle handled");
547        let r = resp.result.expect("ok");
548        assert_eq!(r["protocolVersion"], want);
549        assert_eq!(r["serverInfo"]["name"], "test-server");
550        assert!(
551            r["capabilities"]["resources"]["subscribe"]
552                .as_bool()
553                .unwrap()
554        );
555    }
556
557    #[test]
558    fn initialize_falls_back_to_latest_legacy_for_an_unsupported_version() {
559        // An unknown/too-old version isn't echoed — we answer with our own latest.
560        let req = Request::new(
561            1,
562            "initialize",
563            Some(json!({"protocolVersion": "1999-01-01"})),
564        );
565        let resp = lifecycle_response(&req, &info(), &caps()).expect("handled");
566        let r = resp.result.expect("ok");
567        assert_eq!(r["protocolVersion"], crate::version::PROTOCOL_VERSION);
568    }
569
570    #[test]
571    fn initialize_defaults_when_no_version_is_requested() {
572        let req = Request::new(1, "initialize", Some(json!({})));
573        let resp = lifecycle_response(&req, &info(), &caps()).expect("handled");
574        assert_eq!(
575            resp.result.expect("ok")["protocolVersion"],
576            crate::version::PROTOCOL_VERSION
577        );
578    }
579
580    #[test]
581    fn server_discover_advertises_every_supported_version() {
582        // The modern stateless probe learns our full version list + caps in one call.
583        let req = Request::new(7, method::SERVER_DISCOVER, None);
584        let resp = lifecycle_response(&req, &info(), &caps()).expect("handled");
585        let r = resp.result.expect("ok");
586        assert_eq!(r["resultType"], "complete");
587        let listed = r["supportedVersions"].as_array().expect("array");
588        assert_eq!(
589            listed.len(),
590            crate::version::SUPPORTED_PROTOCOL_VERSIONS.len()
591        );
592        assert!(
593            listed
594                .iter()
595                .any(|v| v == crate::version::FIRST_MODERN_VERSION)
596        );
597        assert!(listed.iter().any(|v| v == crate::version::PROTOCOL_VERSION));
598        assert_eq!(r["serverInfo"]["name"], "test-server");
599    }
600
601    #[test]
602    fn ping_is_an_empty_ok_and_domain_methods_fall_through() {
603        let ping = Request::new(1, "ping", None);
604        assert_eq!(
605            lifecycle_response(&ping, &info(), &caps())
606                .expect("handled")
607                .result,
608            Some(json!({}))
609        );
610        // A non-lifecycle method is the handler's job — the helper declines it.
611        let dom = Request::new(2, "tools/call", None);
612        assert!(lifecycle_response(&dom, &info(), &caps()).is_none());
613    }
614
615    // --- subscription registry ------------------------------------------
616
617    /// A writer whose pushes can be read back off its peer end.
618    fn wired() -> (SharedWriter, BufReader<UnixStream>) {
619        let (tx, rx) = UnixStream::pair().unwrap();
620        // Bound the read so a "should push nothing" assertion can't hang.
621        rx.set_read_timeout(Some(Duration::from_millis(250)))
622            .unwrap();
623        (
624            Arc::new(Mutex::new(ServeStream::Unix(tx))),
625            BufReader::new(rx),
626        )
627    }
628
629    /// Read one pushed notification and return `(method, uri-or-empty)`.
630    fn read_note(rx: &mut BufReader<UnixStream>) -> (String, String) {
631        let bytes = frame::read_line(rx).expect("read").expect("a frame");
632        let v: Value = serde_json::from_slice(&bytes).expect("json");
633        let method = v["method"].as_str().unwrap_or_default().to_string();
634        let uri = v["params"]["uri"].as_str().unwrap_or_default().to_string();
635        (method, uri)
636    }
637
638    /// Assert nothing more was pushed (the bounded read times out / hits EOF).
639    fn assert_silent(rx: &mut BufReader<UnixStream>) {
640        assert!(
641            !matches!(frame::read_line(rx), Ok(Some(_))),
642            "expected no further push"
643        );
644    }
645
646    #[test]
647    fn register_is_idempotent_per_connection() {
648        let subs: SubRegistry = Arc::new(Mutex::new(HashMap::new()));
649        let (w, _rx) = wired();
650        register_subscriber(&subs, "res://a", 1, &w);
651        register_subscriber(&subs, "res://a", 1, &w); // same conn again — no dup
652        let g = subs.lock().unwrap();
653        assert_eq!(g.get("res://a").unwrap().len(), 1);
654    }
655
656    #[test]
657    fn notify_updated_consumes_but_keep_retains() {
658        let subs: SubRegistry = Arc::new(Mutex::new(HashMap::new()));
659        let (w, mut rx) = wired();
660        register_subscriber(&subs, "res://run", 1, &w);
661
662        // keep-variant fires and leaves the subscription in place …
663        notify_resource_updated_keep(&subs, "res://run");
664        let (m, uri) = read_note(&mut rx);
665        assert_eq!(m, method::NOTIFY_RESOURCES_UPDATED);
666        assert_eq!(uri, "res://run");
667        assert!(subs.lock().unwrap().contains_key("res://run"));
668
669        // … the consume-variant fires once and drops the entry.
670        notify_resource_updated(&subs, "res://run");
671        let (_m, uri2) = read_note(&mut rx);
672        assert_eq!(uri2, "res://run");
673        assert!(!subs.lock().unwrap().contains_key("res://run"));
674    }
675
676    #[test]
677    fn drop_and_conn_cleanup_remove_subscriptions() {
678        let subs: SubRegistry = Arc::new(Mutex::new(HashMap::new()));
679        let (w, _rx) = wired();
680        register_subscriber(&subs, "res://a", 1, &w);
681        register_subscriber(&subs, "res://b", 1, &w);
682
683        drop_subscription(&subs, "res://a", 1);
684        assert!(!subs.lock().unwrap().contains_key("res://a"));
685        assert!(subs.lock().unwrap().contains_key("res://b"));
686
687        remove_conn_subscriptions(&subs, 1);
688        assert!(subs.lock().unwrap().is_empty());
689    }
690
691    #[test]
692    fn broadcast_distinct_writes_once_per_connection() {
693        let subs: SubRegistry = Arc::new(Mutex::new(HashMap::new()));
694        let (w, mut rx) = wired();
695        // Same connection subscribed to two resources → one distinct writer.
696        register_subscriber(&subs, "res://a", 1, &w);
697        register_subscriber(&subs, "res://b", 1, &w);
698
699        let note = Notification::new(method::NOTIFY_TOOLS_LIST_CHANGED, None);
700        broadcast_distinct(&subs, &note);
701
702        let (m, _) = read_note(&mut rx);
703        assert_eq!(m, method::NOTIFY_TOOLS_LIST_CHANGED);
704        assert_silent(&mut rx); // not written twice
705    }
706}