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(¬e);
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, ¬e);
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}