Skip to main content

aprender_mcp/
server.rs

1//! `AprMcpServer` — JSON-RPC 2.0 dispatcher for aprender MCP tools.
2//!
3//! # Cancellation model (FALSIFY-MCP-006)
4//!
5//! `tools/call` requests that target `apr.run` are dispatched on a worker
6//! thread so the main stdio loop can continue reading and honour
7//! `notifications/cancelled`. Each in-flight call registers a [`CancelHandle`]
8//! in [`AprMcpServer::in_flight`], keyed by request id. A matching
9//! `notifications/cancelled` signals the worker's cancel channel; the worker
10//! then SIGTERMs the spawned `apr` subprocess, waits
11//! [`crate::tools::subprocess::CANCEL_GRACE_MS`], and SIGKILLs if still alive.
12//!
13//! Non-cancellable tool calls still run on a worker (so future concurrent
14//! calls don't block notifications/cancelled routing) but their cancel
15//! channels are never signalled. `initialize`, `tools/list`, and other
16//! fast synchronous methods dispatch inline on the main thread.
17
18#![allow(clippy::disallowed_methods)] // serde_json::json! macro expands to .unwrap() internally
19
20use crate::types::{
21    JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ToolCallResult, ToolDefinition,
22};
23use std::collections::HashMap;
24use std::sync::mpsc::{self, Sender};
25use std::sync::{Arc, Mutex};
26
27/// Callback used by tools to emit `notifications/progress` messages back to
28/// the MCP client while a long-running `tools/call` is still in flight.
29///
30/// FALSIFY-MCP-PROGRESS-001: in stdio mode the dispatcher passes a sink that
31/// writes each notification as one JSON line to the shared stdout handle
32/// (guarded by the same mutex as final responses). In-process tests use an
33/// `Arc<Mutex<Vec<_>>>`-backed sink to assert the outgoing wire format.
34///
35/// Must be `Send` because the sink is moved into the worker thread that
36/// `run_stdio` spawns for every `tools/call`.
37pub type NotificationSink = Box<dyn Fn(JsonRpcNotification) + Send + Sync>;
38
39/// Per-request cancellation record held in [`AprMcpServer::in_flight`].
40///
41/// Only `apr.run` currently honours cancellation. Entries for other tools
42/// are still registered (so a stray `notifications/cancelled` doesn't log
43/// a warning) but their senders are never used.
44#[derive(Debug)]
45pub struct CancelHandle {
46    /// Sender side of the worker's cancel mpsc. `send(())` causes the
47    /// subprocess poll loop to SIGTERM its child.
48    pub cancel_tx: Sender<()>,
49}
50
51/// Map of in-flight `tools/call` requests keyed by JSON-RPC id.
52///
53/// The id is stored as a raw `serde_json::Value` because the MCP spec
54/// permits both integer and string ids.
55type InFlight = Arc<Mutex<HashMap<serde_json::Value, CancelHandle>>>;
56
57/// MCP server exposing the `apr` CLI as tools.
58///
59/// M1: `initialize`, `tools/list`, `tools/call` with `apr.version`.
60/// M3: `notifications/cancelled` routed to in-flight `apr.run` workers.
61#[derive(Debug)]
62pub struct AprMcpServer {
63    in_flight: InFlight,
64    /// Join handles for `tools/call` workers spawned by
65    /// [`Self::spawn_tools_call_worker`]. The read loop MUST join these
66    /// before returning on EOF, otherwise the process exits while a worker
67    /// still owes the client a response and the answer is lost — see
68    /// [`Self::serve_stream`].
69    #[cfg(feature = "native")]
70    workers: Vec<std::thread::JoinHandle<()>>,
71    /// The dispatch function a `tools/call` worker runs, always
72    /// [`dispatch_tool_call_with_sink`] outside tests.
73    ///
74    /// It is a field rather than a hardcoded call so that
75    /// FALSIFY-MCP-DRAIN-005 can drive a PANICKING tool through the whole
76    /// real stdio path — `serve_stream` → `read_loop` →
77    /// `route_stdio_message` → `spawn_tools_call_worker` → the worker thread
78    /// — and observe what the client actually receives. Every registered tool
79    /// is a subprocess wrapper or a pure metadata read, so no real
80    /// `tools/call` argument can be made to panic on demand, and without this
81    /// seam the panic guard could only ever be tested one hop away from the
82    /// wiring that has to call it.
83    ///
84    /// The seam cannot hide a stubbed default: the other `serve_stream_*`
85    /// tests run an untouched server and assert the genuine `apr.version`
86    /// payload comes back out of stdout, and
87    /// `default_worker_dispatch_is_the_real_tool_dispatcher` asserts it
88    /// directly.
89    #[cfg(feature = "native")]
90    worker_dispatch: WorkerDispatchFn,
91    /// The tools this server serves (PMAT-3954). apr's own set from `inventory` for
92    /// [`Self::new`]; a caller's set for [`Self::with_tools`]. Every `tools/*` path —
93    /// the sync one and the worker threads — dispatches through THIS index, never a global.
94    index: Arc<crate::tools::ToolIndex>,
95    /// `serverInfo.name` / `serverInfo.version` answered by `initialize`.
96    server_name: String,
97    server_version: String,
98}
99
100/// The worker seam's signature: [`crate::tools::DispatchFn`] plus the index to dispatch
101/// through, so a worker thread serves the server's OWN tools (PMAT-3954).
102#[cfg(feature = "native")]
103type WorkerDispatchFn = fn(
104    &crate::tools::ToolIndex,
105    &serde_json::Value,
106    &mpsc::Receiver<()>,
107    Option<&NotificationSink>,
108    Option<serde_json::Value>,
109) -> ToolCallResult;
110
111#[cfg(feature = "apr-tools")]
112impl Default for AprMcpServer {
113    fn default() -> Self {
114        Self::with_tools(crate::SERVER_NAME, env!("CARGO_PKG_VERSION"), tool_index())
115    }
116}
117
118impl AprMcpServer {
119    /// Construct apr's server: every tool registered through `inventory`.
120    #[cfg(feature = "apr-tools")]
121    #[must_use]
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Construct a server for a CALLER's tool set (PMAT-3954): `initialize` answers
127    /// `name`/`version`, `tools/list` is exactly `index`, and `tools/call` dispatches
128    /// through it — on the sync path and in every worker thread. No apr tool is served.
129    #[must_use]
130    pub fn with_tools(
131        name: impl Into<String>,
132        version: impl Into<String>,
133        index: Arc<crate::tools::ToolIndex>,
134    ) -> Self {
135        Self {
136            in_flight: InFlight::default(),
137            #[cfg(feature = "native")]
138            workers: Vec::new(),
139            #[cfg(feature = "native")]
140            worker_dispatch: dispatch_tool_call_with_sink,
141            index,
142            server_name: name.into(),
143            server_version: version.into(),
144        }
145    }
146
147    /// Dispatch a single JSON-RPC request synchronously.
148    ///
149    /// This is the in-process test entry point. It does NOT exercise the
150    /// threading / cancellation machinery — `apr.run` runs inline with a
151    /// dummy never-firing cancel receiver and NO notification sink is
152    /// attached, so `apr.finetune` silently falls back to its synchronous
153    /// path even if the request carries `params._meta.progressToken`. Use
154    /// [`Self::run_stdio`] for the full M3 dispatcher or
155    /// [`Self::handle_request_with_sink`] to drive FALSIFY-MCP-PROGRESS-001
156    /// in tests.
157    ///
158    /// The dispatcher enforces one protocol-level invariant before routing:
159    /// FALSIFY-MCP-005 (`jsonrpc` must be exactly `"2.0"` or the response is
160    /// `-32600 Invalid Request`). Version negotiation is NOT a gate — see
161    /// [`Self::handle_initialize`] (FALSIFY-MCP-007).
162    #[must_use]
163    pub fn handle_request(&mut self, request: &JsonRpcRequest) -> JsonRpcResponse {
164        if request.jsonrpc != "2.0" {
165            return JsonRpcResponse::error(
166                request.id.clone(),
167                -32600,
168                format!(
169                    "Invalid Request: jsonrpc must be \"2.0\", got \"{}\"",
170                    request.jsonrpc
171                ),
172            );
173        }
174
175        match request.method.as_str() {
176            "initialize" => self.handle_initialize(request),
177            "tools/list" => self.handle_tools_list(request),
178            "tools/call" => self.handle_tools_call_sync(request),
179            // MCP base protocol utility: `ping` is not a capability and is
180            // never advertised, so a client may send it at any time to check
181            // liveness. The receiver "MUST respond promptly with an empty
182            // response". Answering -32601 makes keepalive clients conclude
183            // the server is dead and restart it.
184            "ping" => JsonRpcResponse::success(request.id.clone(), serde_json::json!({})),
185            other => JsonRpcResponse::error(
186                request.id.clone(),
187                -32601,
188                format!("Method not found: {other}"),
189            ),
190        }
191    }
192
193    /// Handle `initialize`.
194    ///
195    /// FALSIFY-MCP-007: version negotiation is a *proposal*, not a gate. The
196    /// MCP lifecycle says that if the server supports the requested version it
197    /// responds with that version, and OTHERWISE responds with a version it
198    /// does support, leaving the client to decide whether to proceed or
199    /// disconnect. Returning `-32602` on a mismatch aborts the handshake, so a
200    /// client negotiating anything newer than ours (Claude Code and Cursor
201    /// both propose 2025-03-26 / 2025-06-18) can never connect at all — even
202    /// though the wire protocol it would then speak is one we handle.
203    ///
204    /// We support exactly one version, so the reply always carries
205    /// [`crate::PROTOCOL_VERSION`] regardless of what was proposed.
206    fn handle_initialize(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
207        JsonRpcResponse::success(
208            request.id.clone(),
209            serde_json::json!({
210                "protocolVersion": crate::PROTOCOL_VERSION,
211                "capabilities": {
212                    "tools": { "listChanged": false }
213                },
214                "serverInfo": {
215                    "name": self.server_name,
216                    "version": self.server_version,
217                },
218            }),
219        )
220    }
221
222    fn handle_tools_list(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
223        let tools: Vec<ToolDefinition> = self.tool_definitions();
224        JsonRpcResponse::success(request.id.clone(), serde_json::json!({ "tools": tools }))
225    }
226
227    /// Synchronous fallback used by [`Self::handle_request`]. `apr.run`
228    /// runs with a never-firing cancel receiver — cancellation is only
229    /// wired by the stdio loop in [`Self::run_stdio`]. No notifications are
230    /// emitted from this path.
231    fn handle_tools_call_sync(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
232        let (_tx, rx) = mpsc::channel::<()>();
233        let result = dispatch_tool_call(&self.index, &request.params, &rx, None);
234        JsonRpcResponse::success(
235            request.id.clone(),
236            serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
237        )
238    }
239
240    /// Dispatch one request with an explicit notification sink (test entry
241    /// point for FALSIFY-MCP-PROGRESS-001).
242    ///
243    /// The sink is only exercised for `tools/call` dispatches where
244    /// (a) the client supplied `params._meta.progressToken` on the original
245    /// request AND (b) the target tool supports progress streaming
246    /// (currently `apr.finetune` and `apr.run`). Other methods ignore the
247    /// sink.
248    ///
249    /// `handle_request_with_sink` returns `None` for notifications (methods
250    /// prefixed with `notifications/`) because notifications have no id and
251    /// MUST NOT receive a response per JSON-RPC 2.0. All other methods
252    /// return `Some(response)`.
253    #[must_use]
254    pub fn handle_request_with_sink(
255        &mut self,
256        request: &JsonRpcRequest,
257        sink: &NotificationSink,
258    ) -> Option<JsonRpcResponse> {
259        if request.jsonrpc != "2.0" {
260            return Some(JsonRpcResponse::error(
261                request.id.clone(),
262                -32600,
263                format!(
264                    "Invalid Request: jsonrpc must be \"2.0\", got \"{}\"",
265                    request.jsonrpc
266                ),
267            ));
268        }
269
270        if request.method.starts_with("notifications/") {
271            return None;
272        }
273
274        if request.method != "tools/call" {
275            return Some(self.handle_request(request));
276        }
277
278        let progress_token = extract_progress_token(&request.params);
279        let (_tx, rx) = mpsc::channel::<()>();
280        let sink_for_dispatch = progress_token.as_ref().map(|_| sink);
281        let result = dispatch_tool_call_with_sink(
282            &self.index,
283            &request.params,
284            &rx,
285            sink_for_dispatch,
286            progress_token,
287        );
288        Some(JsonRpcResponse::success(
289            request.id.clone(),
290            serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
291        ))
292    }
293
294    /// All tool definitions registered on this server.
295    ///
296    /// HELIX-IDEA-002 / FALSIFY-INVENTORY-001: returns whatever
297    /// [`crate::tools::ToolIndex::definitions`] contains, which is
298    /// populated at startup by iterating
299    /// `inventory::iter::<McpToolEntry>`. Adding a new tool requires only
300    /// a `register_mcp_tool!` invocation in that tool's module — no
301    /// edit here.
302    #[must_use]
303    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
304        self.index.definitions().to_vec()
305    }
306
307    /// Register a new in-flight request and return its cancel receiver.
308    ///
309    /// Exposed for testing the cancellation routing without spawning a real
310    /// worker. Production code calls this from [`Self::run_stdio`].
311    #[must_use]
312    pub fn register_in_flight(in_flight: &InFlight, id: serde_json::Value) -> mpsc::Receiver<()> {
313        let (tx, rx) = mpsc::channel::<()>();
314        let mut guard = in_flight
315            .lock()
316            .expect("in_flight mutex not poisoned during register");
317        guard.insert(id, CancelHandle { cancel_tx: tx });
318        rx
319    }
320
321    /// Route a `notifications/cancelled` to the matching in-flight request.
322    ///
323    /// Idempotent: repeated cancels for the same id after the first are
324    /// silently dropped. References to completed / unknown ids are no-ops.
325    /// Returns `true` iff a live handle was signalled.
326    pub fn cancel_in_flight(in_flight: &InFlight, id: &serde_json::Value) -> bool {
327        let mut guard = in_flight
328            .lock()
329            .expect("in_flight mutex not poisoned during cancel");
330        if let Some(handle) = guard.remove(id) {
331            // Best-effort: if the worker already completed and dropped its
332            // receiver, the send fails silently — exactly the no-op we want.
333            let _ = handle.cancel_tx.send(());
334            true
335        } else {
336            false
337        }
338    }
339
340    /// Deregister an in-flight id after its worker finishes. Safe to call
341    /// even if the id was already removed by a concurrent cancel.
342    fn deregister_in_flight(in_flight: &InFlight, id: &serde_json::Value) {
343        if let Ok(mut guard) = in_flight.lock() {
344            guard.remove(id);
345        }
346    }
347
348    /// Run the server over stdio (blocking).
349    ///
350    /// Thin wrapper: binds [`Self::serve_stream`] to the real stdin/stdout.
351    /// All loop behaviour — and every falsifier for it — lives in
352    /// `serve_stream`, which is generic over its streams precisely so the
353    /// read loop can be exercised in-process. `run_stdio` itself is the one
354    /// piece that cannot be unit-tested, so it is kept to two lines with no
355    /// logic of its own.
356    ///
357    /// # Errors
358    /// Returns an error if stdin/stdout I/O fails.
359    #[cfg(feature = "native")]
360    pub fn run_stdio(&mut self) -> std::io::Result<()> {
361        let stdin = std::io::stdin();
362        let reader = stdin.lock();
363        self.serve_stream(reader, Arc::new(Mutex::new(std::io::stdout())))
364    }
365
366    /// Serve one JSON-RPC-over-newline-delimited-JSON session to completion.
367    ///
368    /// Reads one message per line from `reader`. `initialize`, `tools/list`,
369    /// `ping`, and unknown methods dispatch inline. `tools/call` spawns a
370    /// worker thread so a subsequent `notifications/cancelled` message can
371    /// flow through the main loop and signal the worker's cancel channel.
372    /// Workers write their responses directly to `out` (guarded by a mutex)
373    /// so the main loop never has to wait on them mid-stream.
374    ///
375    /// Three transport invariants live here, all found by dogfooding the
376    /// shipped binary and all invisible to a dispatcher-level test:
377    ///
378    /// * **FALSIFY-MCP-010** — on EOF the loop MUST join every worker it
379    ///   spawned before returning. Without that join the process exits the
380    ///   instant stdin closes, so the canonical `printf ... | apr mcp`
381    ///   invocation loses every `tools/call` result while still exiting 0 —
382    ///   indistinguishable, to the client, from a tool that produced no
383    ///   output.
384    /// * **FALSIFY-MCP-011** — lines are read as BYTES and decoded per line.
385    ///   A line that is not valid UTF-8 is a malformed *message*, answered
386    ///   with `-32700`, not a transport failure that takes the session down
387    ///   with it.
388    /// * **FALSIFY-MCP-DRAIN-001** (#2608) — EOF is not the only way out of
389    ///   the read loop. Every `?` inside it (a stdin read error, a stdout
390    ///   write error) is an exit too, and each one used to abandon the
391    ///   in-flight workers exactly the way the 0.63.0 EOF path did. The drain
392    ///   therefore lives here, on the *only* return path, rather than at the
393    ///   bottom of the loop where three of the four exits skip it.
394    ///
395    /// `W` must be `Send + 'static` because the same handle is shared with
396    /// every worker thread.
397    ///
398    /// # Errors
399    /// Returns an error if reading or writing the streams fails. The error is
400    /// reported only AFTER the drain, so a failed session still delivers the
401    /// answers it already owes.
402    #[cfg(feature = "native")]
403    pub fn serve_stream<R, W>(&mut self, reader: R, out: Arc<Mutex<W>>) -> std::io::Result<()>
404    where
405        R: std::io::BufRead,
406        W: std::io::Write + Send + 'static,
407    {
408        let outcome = self.read_loop(reader, &out);
409
410        // FALSIFY-MCP-010 / FALSIFY-MCP-DRAIN-001: drain before returning, on
411        // EVERY exit. EOF means the client sent everything it intends to, NOT
412        // that it stopped wanting answers — and an I/O error mid-session says
413        // even less about the requests already accepted.
414        self.join_workers();
415        outcome
416    }
417
418    /// The read loop proper. Separated from [`Self::serve_stream`] so that the
419    /// worker drain wraps it, rather than sitting on one of its exits.
420    #[cfg(feature = "native")]
421    fn read_loop<R, W>(&mut self, mut reader: R, out: &Arc<Mutex<W>>) -> std::io::Result<()>
422    where
423        R: std::io::BufRead,
424        W: std::io::Write + Send + 'static,
425    {
426        let mut buf: Vec<u8> = Vec::new();
427
428        loop {
429            buf.clear();
430            if reader.read_until(b'\n', &mut buf)? == 0 {
431                break; // EOF
432            }
433            while matches!(buf.last(), Some(b'\n' | b'\r')) {
434                buf.pop();
435            }
436
437            // FALSIFY-MCP-011: one bad byte must cost one message, not the
438            // session. `BufRead::lines()` surfaced this as an io::Error that
439            // propagated out of the loop and killed the process (exit 1), so
440            // every request after the bad byte went unanswered.
441            let Ok(line) = std::str::from_utf8(&buf) else {
442                let resp =
443                    JsonRpcResponse::error(None, -32700, "Parse error: message is not valid UTF-8");
444                write_response(out, &resp)?;
445                continue;
446            };
447
448            if line.trim().is_empty() {
449                continue;
450            }
451
452            match parse_incoming(line) {
453                Ok(req) => self.route_stdio_message(req, out)?,
454                Err(resp) => write_response(out, &resp)?,
455            }
456
457            self.reap_finished_workers();
458        }
459
460        Ok(())
461    }
462
463    /// Drop join handles for workers that have already finished, so a
464    /// long-lived session does not accumulate one handle per tool call.
465    /// Never blocks — `is_finished` is a non-blocking check.
466    #[cfg(feature = "native")]
467    fn reap_finished_workers(&mut self) {
468        self.workers.retain(|h| !h.is_finished());
469    }
470
471    /// Build the response a `tools/call` worker owes its client, converting a
472    /// panic inside tool dispatch into a `-32603` for the SAME id.
473    ///
474    /// FALSIFY-MCP-DRAIN-002 (#2608): the worker wrote a response only on the
475    /// success path, and [`Self::join_workers`] deliberately swallows a
476    /// worker panic (`let _ = handle.join()`). A tool that panicked therefore
477    /// left its request answered by NEITHER a result NOR an error — the same
478    /// protocol violation as the EOF drop, reached by a different route, and
479    /// equally silent: rc stays 0 and the client waits forever.
480    ///
481    /// Only dispatch runs under `catch_unwind`; the write and the registry
482    /// cleanup stay outside it, so a caught panic is always one that happened
483    /// BEFORE any bytes were written and can never produce a second response
484    /// for the same id.
485    #[cfg(feature = "native")]
486    fn worker_response<F>(id: &serde_json::Value, dispatch: F) -> JsonRpcResponse
487    where
488        F: FnOnce() -> ToolCallResult,
489    {
490        // AssertUnwindSafe: on the panic path every captured value is dropped
491        // untouched — nothing is read back, so there is no broken invariant to
492        // observe. The alternative (propagating) is the silent drop itself.
493        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(dispatch)) {
494            Ok(result) => JsonRpcResponse::success(
495                Some(id.clone()),
496                serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
497            ),
498            Err(payload) => JsonRpcResponse::error(
499                Some(id.clone()),
500                -32603,
501                format!(
502                    "Internal error: tool panicked: {}",
503                    panic_message(payload.as_ref())
504                ),
505            ),
506        }
507    }
508
509    /// Block until every in-flight `tools/call` worker has written its
510    /// response. Called once, on the single exit path of
511    /// [`Self::serve_stream`].
512    ///
513    /// A panicking worker is ignored rather than propagated: the client is
514    /// owed whatever the surviving workers produced, and the panicking
515    /// worker's own request was already answered with a `-32603` by
516    /// [`Self::worker_response`].
517    #[cfg(feature = "native")]
518    fn join_workers(&mut self) {
519        for handle in std::mem::take(&mut self.workers) {
520            let _ = handle.join();
521        }
522    }
523
524    /// Dispatch one parsed request within the read loop. Separated from
525    /// [`Self::serve_stream`] for testability.
526    #[cfg(feature = "native")]
527    fn route_stdio_message<W>(
528        &mut self,
529        req: JsonRpcRequest,
530        stdout: &Arc<Mutex<W>>,
531    ) -> std::io::Result<()>
532    where
533        W: std::io::Write + Send + 'static,
534    {
535        // FALSIFY-MCP-005: jsonrpc field gate runs before method dispatch.
536        if req.jsonrpc != "2.0" {
537            let resp = JsonRpcResponse::error(
538                req.id.clone(),
539                -32600,
540                format!(
541                    "Invalid Request: jsonrpc must be \"2.0\", got \"{}\"",
542                    req.jsonrpc
543                ),
544            );
545            return write_response(stdout, &resp);
546        }
547
548        match req.method.as_str() {
549            // Notifications have no `id` and MUST NOT receive a response.
550            "notifications/cancelled" => {
551                if let Some(request_id) = req.params.get("requestId").cloned() {
552                    let _ = Self::cancel_in_flight(&self.in_flight, &request_id);
553                }
554                Ok(())
555            }
556            "notifications/initialized" => {
557                // Client handshake ack — no response, no state change.
558                Ok(())
559            }
560            "tools/call" => self.spawn_tools_call_worker(req, stdout),
561            // Fast inline paths.
562            _ => {
563                // FALSIFY-MCP-009: JSON-RPC 2.0 §4.1 — a Request object
564                // without an `id` member is a *Notification*, and "The Server
565                // MUST NOT reply to a Notification." The `notifications/*`
566                // method prefix is an MCP convention, but conformance is
567                // determined by the *absence of an id*, not the method name. A
568                // client that sends e.g. `{"jsonrpc":"2.0","method":"initialize"}`
569                // (no id) or an unknown method with no id is issuing a
570                // notification; emitting a response with `id:null` would
571                // corrupt the stream for a strict peer. Drop it silently.
572                if req.id.is_none() {
573                    return Ok(());
574                }
575                let resp = self.handle_request(&req);
576                write_response(stdout, &resp)
577            }
578        }
579    }
580
581    #[cfg(feature = "native")]
582    fn spawn_tools_call_worker<W>(
583        &mut self,
584        req: JsonRpcRequest,
585        stdout: &Arc<Mutex<W>>,
586    ) -> std::io::Result<()>
587    where
588        W: std::io::Write + Send + 'static,
589    {
590        // Notifications would arrive with id = None; tools/call must have
591        // an id per JSON-RPC. Defensive: if it's missing, respond inline
592        // with an error so the client sees the failure immediately.
593        let Some(id) = req.id.clone() else {
594            let resp =
595                JsonRpcResponse::error(None, -32600, "Invalid Request: tools/call requires an id");
596            return write_response(stdout, &resp);
597        };
598
599        let cancel_rx = Self::register_in_flight(&self.in_flight, id.clone());
600        let stdout_clone = Arc::clone(stdout);
601        let in_flight_clone = Arc::clone(&self.in_flight);
602        let params = req.params.clone();
603        let id_for_worker = id.clone();
604        let progress_token = extract_progress_token(&params);
605
606        // Build a stdout-backed notification sink for this worker. The sink
607        // shares the response stdout mutex so progress lines and the final
608        // response can never interleave. Per MCP spec the sink is only
609        // wired when the client advertised a progressToken.
610        let sink_stdout = Arc::clone(stdout);
611        let sink: NotificationSink = Box::new(move |notif| {
612            // Best-effort: a broken stdout means the client disconnected.
613            let _ = write_notification(&sink_stdout, &notif);
614        });
615
616        // Thread spawn is infallible here in practice, but propagate the
617        // error rather than unwrapping so we stay in the "no panics" lane.
618        let builder = std::thread::Builder::new().name(format!("apr-mcp-call-{id}"));
619        let dispatch = self.worker_dispatch;
620        let index = Arc::clone(&self.index);
621        let spawn_result = builder.spawn(move || {
622            // FALSIFY-MCP-DRAIN-002/005: dispatch runs INSIDE worker_response,
623            // never beside it. Calling `dispatch(...)` directly here and
624            // building the success response from its return value is exactly
625            // the pre-#2608 code, and is what FALSIFY-MCP-DRAIN-005 exists to
626            // turn red.
627            let resp = Self::worker_response(&id_for_worker, || {
628                let sink_ref = progress_token.as_ref().map(|_| &sink);
629                dispatch(&index, &params, &cancel_rx, sink_ref, progress_token)
630            });
631            // Best-effort: a broken stdout means the client disconnected,
632            // which we can't recover from anyway.
633            let _ = write_response(&stdout_clone, &resp);
634            Self::deregister_in_flight(&in_flight_clone, &id_for_worker);
635        });
636
637        match spawn_result {
638            Ok(handle) => {
639                // FALSIFY-MCP-010: keep the handle so EOF can wait for this
640                // worker's response instead of exiting out from under it.
641                self.workers.push(handle);
642                Ok(())
643            }
644            Err(e) => {
645                // Failed to spawn — clean up the registry entry we just
646                // inserted and report the failure inline.
647                Self::deregister_in_flight(&self.in_flight, &id);
648                let resp = JsonRpcResponse::error(
649                    Some(id),
650                    -32603,
651                    format!("Internal error: failed to spawn worker thread: {e}"),
652                );
653                write_response(stdout, &resp)
654            }
655        }
656    }
657
658    /// Handle for tests that want to inspect the in-flight registry.
659    #[must_use]
660    pub fn in_flight_handle(&self) -> InFlight {
661        Arc::clone(&self.in_flight)
662    }
663}
664
665/// Shared tool-call dispatch logic used by both the sync and stdio paths.
666///
667/// `cancel_rx` is forwarded to `apr.run` only; the other tools ignore it.
668/// Callers that never need progress streaming can keep using this wrapper;
669/// the [`dispatch_tool_call_with_sink`] variant exposes the
670/// FALSIFY-MCP-PROGRESS-001 path.
671fn dispatch_tool_call(
672    index: &crate::tools::ToolIndex,
673    params: &serde_json::Value,
674    cancel_rx: &mpsc::Receiver<()>,
675    sink: Option<&NotificationSink>,
676) -> ToolCallResult {
677    dispatch_tool_call_with_sink(index, params, cancel_rx, sink, None)
678}
679
680/// Full dispatch variant with optional `NotificationSink` + `progressToken`.
681///
682/// FALSIFY-MCP-PROGRESS-001 / FALSIFY-MCP-PROGRESS-002: when `sink` and
683/// `progress_token` are both `Some`, tools that support streaming
684/// (`apr.finetune` and `apr.run`) forward each stdout line as a
685/// `notifications/progress` message via `sink` before returning the final
686/// `ToolCallResult`. Tools that don't support streaming ignore the sink and
687/// run synchronously.
688fn dispatch_tool_call_with_sink(
689    index: &crate::tools::ToolIndex,
690    params: &serde_json::Value,
691    cancel_rx: &mpsc::Receiver<()>,
692    sink: Option<&NotificationSink>,
693    progress_token: Option<serde_json::Value>,
694) -> ToolCallResult {
695    let name = params.get("name").and_then(|v| v.as_str());
696    let arguments = params
697        .get("arguments")
698        .cloned()
699        .unwrap_or_else(|| serde_json::json!({}));
700
701    // HELIX-IDEA-002 / FALSIFY-INVENTORY-003: dispatch goes through the
702    // inventory-built name → fn-pointer index. Every shipped tool's
703    // module owns a `dispatch` shim that adapts to the unified
704    // `DispatchFn` signature (FALSIFY-MCP-PROGRESS-002 still applies for
705    // `apr.run` and `apr.finetune`; sink + progress_token forward through
706    // the shim as before).
707    let Some(name) = name else {
708        return ToolCallResult::error("Missing tool name");
709    };
710    match index.dispatch_for(name) {
711        Some(dispatch_fn) => dispatch_fn(&arguments, cancel_rx, sink, progress_token),
712        None => ToolCallResult::error(format!("Unknown tool: {name}")),
713    }
714}
715
716/// Module-local inventory cache. Built once on first access via
717/// [`crate::tools::ToolIndex::from_inventory`]; that call panics
718/// (FALSIFY-INVENTORY-002) if two tools advertise the same name, so a
719/// duplicate-registration regression fails every test that hits the
720/// dispatcher rather than silently shadowing one entry.
721#[cfg(feature = "apr-tools")]
722fn tool_index() -> Arc<crate::tools::ToolIndex> {
723    static INDEX: std::sync::OnceLock<Arc<crate::tools::ToolIndex>> = std::sync::OnceLock::new();
724    Arc::clone(INDEX.get_or_init(|| Arc::new(crate::tools::ToolIndex::from_inventory())))
725}
726
727/// Pull `params._meta.progressToken` out of a `tools/call` request. Returns
728/// `None` when the field is absent — per MCP 2024-11-05 the server MUST NOT
729/// emit progress notifications in that case.
730fn extract_progress_token(params: &serde_json::Value) -> Option<serde_json::Value> {
731    params
732        .get("_meta")
733        .and_then(|m| m.get("progressToken"))
734        .cloned()
735}
736
737#[cfg(feature = "native")]
738/// JSON type name, in JSON Schema vocabulary, for diagnostics.
739fn json_type_name(value: &serde_json::Value) -> &'static str {
740    crate::tools::args::json_type_name(value)
741}
742
743/// Parse one incoming line into a [`JsonRpcRequest`], or into the JSON-RPC
744/// error response that must be sent instead.
745///
746/// FALSIFY-MCP-012: JSON-RPC 2.0 draws a line the old
747/// `serde_json::from_str::<JsonRpcRequest>` path could not see. `-32700 Parse
748/// error` means "the payload was not valid JSON". A payload that IS valid JSON
749/// but is not a valid Request object is `-32600 Invalid Request`, and its
750/// response must echo the request's `id` so the client can correlate the
751/// failure. Deserializing straight into the struct reported a missing
752/// `jsonrpc` or `method` field as a *parse* error with `id: null` — while a
753/// jsonrpc field with the WRONG VALUE was already correctly reported as
754/// -32600 with the id echoed, so the server disagreed with itself.
755///
756/// A batch ARRAY gets its own message. Batching is optional for a 2024-11-05
757/// server and we decline it, but the old behaviour surfaced serde's attempt to
758/// read the first array element as the `jsonrpc` string — "invalid type: map,
759/// expected a string at line 1 column 1" — which names neither batching nor
760/// arrays and points at a '[' that is perfectly valid JSON.
761/// The error side is boxed because `JsonRpcResponse` is large enough that
762/// clippy's `result_large_err` fires on the bare form, and the error path is
763/// the rare one.
764fn parse_incoming(line: &str) -> Result<JsonRpcRequest, Box<JsonRpcResponse>> {
765    let value: serde_json::Value = serde_json::from_str(line).map_err(|e| {
766        Box::new(JsonRpcResponse::error(
767            None,
768            -32700,
769            format!("Parse error: {e}"),
770        ))
771    })?;
772
773    if value.is_array() {
774        return Err(Box::new(JsonRpcResponse::error(
775            None,
776            -32600,
777            "Invalid Request: JSON-RPC batch arrays are not supported; \
778             send one request per line",
779        )));
780    }
781
782    let Some(obj) = value.as_object() else {
783        return Err(Box::new(JsonRpcResponse::error(
784            None,
785            -32600,
786            format!(
787                "Invalid Request: a request must be a JSON object, got {}",
788                json_type_name(&value)
789            ),
790        )));
791    };
792
793    // A null id is the same as an absent one (serde maps JSON null to None for
794    // `Option<Value>`), which is what keeps FALSIFY-MCP-009's notification
795    // rule intact.
796    let id = obj.get("id").filter(|v| !v.is_null()).cloned();
797
798    let jsonrpc = match obj.get("jsonrpc") {
799        Some(serde_json::Value::String(s)) => s.clone(),
800        Some(other) => {
801            return Err(Box::new(JsonRpcResponse::error(
802                id,
803                -32600,
804                format!(
805                    "Invalid Request: \"jsonrpc\" must be the string \"2.0\", got {}",
806                    json_type_name(other)
807                ),
808            )));
809        }
810        None => {
811            return Err(Box::new(JsonRpcResponse::error(
812                id,
813                -32600,
814                "Invalid Request: missing required field \"jsonrpc\"",
815            )));
816        }
817    };
818
819    let method = match obj.get("method") {
820        Some(serde_json::Value::String(s)) => s.clone(),
821        Some(other) => {
822            return Err(Box::new(JsonRpcResponse::error(
823                id,
824                -32600,
825                format!(
826                    "Invalid Request: \"method\" must be a string, got {}",
827                    json_type_name(other)
828                ),
829            )));
830        }
831        None => {
832            return Err(Box::new(JsonRpcResponse::error(
833                id,
834                -32600,
835                "Invalid Request: missing required field \"method\"",
836            )));
837        }
838    };
839
840    Ok(JsonRpcRequest {
841        jsonrpc,
842        id,
843        method,
844        params: obj
845            .get("params")
846            .cloned()
847            .unwrap_or(serde_json::Value::Null),
848    })
849}
850
851/// Best-effort rendering of a `catch_unwind` payload.
852///
853/// `panic!("msg")` yields a `&'static str`, `panic!("{x}")` a `String`; a
854/// payload of any other type carries no message we can read, so the client is
855/// told that rather than being handed an empty error.
856#[cfg(feature = "native")]
857fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
858    if let Some(s) = payload.downcast_ref::<&'static str>() {
859        (*s).to_string()
860    } else if let Some(s) = payload.downcast_ref::<String>() {
861        s.clone()
862    } else {
863        "panic payload is not a string".to_string()
864    }
865}
866
867fn write_response<W: std::io::Write>(
868    stdout: &Arc<Mutex<W>>,
869    resp: &JsonRpcResponse,
870) -> std::io::Result<()> {
871    let json = serde_json::to_string(resp)?;
872    let mut guard = stdout
873        .lock()
874        .map_err(|e| std::io::Error::other(format!("stdout mutex poisoned: {e}")))?;
875    writeln!(&mut *guard, "{json}")?;
876    guard.flush()?;
877    Ok(())
878}
879
880/// FALSIFY-MCP-PROGRESS-001: write one `notifications/progress` line to
881/// stdout under the same mutex used for final responses. Called from the
882/// worker-local `NotificationSink` built in
883/// [`AprMcpServer::spawn_tools_call_worker`].
884#[cfg(feature = "native")]
885fn write_notification<W: std::io::Write>(
886    stdout: &Arc<Mutex<W>>,
887    notif: &JsonRpcNotification,
888) -> std::io::Result<()> {
889    let json = notif.to_json_line()?;
890    let mut guard = stdout
891        .lock()
892        .map_err(|e| std::io::Error::other(format!("stdout mutex poisoned: {e}")))?;
893    writeln!(&mut *guard, "{json}")?;
894    guard.flush()?;
895    Ok(())
896}
897
898#[cfg(all(test, feature = "apr-tools"))] // drives apr's own tools; the caller-set path is tests/falsify_toolset_generic.rs
899#[allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
900mod tests {
901    use super::*;
902
903    fn make_request(method: &str, params: serde_json::Value) -> JsonRpcRequest {
904        JsonRpcRequest {
905            jsonrpc: "2.0".to_string(),
906            id: Some(serde_json::json!(1)),
907            method: method.to_string(),
908            params,
909        }
910    }
911
912    /// Drive a whole session through [`AprMcpServer::serve_stream`] with
913    /// in-memory streams and return the response lines it wrote.
914    ///
915    /// This is the point of `serve_stream` being generic: FALSIFY-MCP-010 and
916    /// -011 live in the read loop, not in request handling, so a
917    /// `handle_request` test cannot see either. Driving the real loop over a
918    /// byte slice reproduces both defects exactly — including invalid UTF-8,
919    /// which cannot even be expressed as a `&str` input.
920    #[cfg(feature = "native")]
921    fn drive(input: &[u8]) -> Vec<serde_json::Value> {
922        let out = Arc::new(Mutex::new(Vec::<u8>::new()));
923        let mut server = AprMcpServer::new();
924        server
925            .serve_stream(std::io::Cursor::new(input.to_vec()), Arc::clone(&out))
926            .expect("serve_stream must not propagate an error out of the session");
927
928        parse_written_lines(&out)
929    }
930
931    /// Parse whatever a session wrote to `out` into one JSON value per line.
932    #[cfg(feature = "native")]
933    fn parse_written_lines(out: &Arc<Mutex<Vec<u8>>>) -> Vec<serde_json::Value> {
934        let guard = out.lock().expect("output mutex not poisoned");
935        String::from_utf8_lossy(&guard)
936            .lines()
937            .filter(|l| !l.trim().is_empty())
938            .map(|l| {
939                serde_json::from_str::<serde_json::Value>(l)
940                    .unwrap_or_else(|e| panic!("non-JSON output line {l:?}: {e}"))
941            })
942            .collect()
943    }
944
945    #[cfg(feature = "native")]
946    fn find_id(responses: &[serde_json::Value], id: i64) -> Option<&serde_json::Value> {
947        responses.iter().find(|r| r["id"] == serde_json::json!(id))
948    }
949
950    #[cfg(feature = "native")]
951    const INIT_LINE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}"#;
952    #[cfg(feature = "native")]
953    const CALL_LINE: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apr.version","arguments":{}}}"#;
954
955    /// FALSIFY-MCP-010: every request carrying an `id` must be answered before
956    /// the loop returns, INCLUDING a `tools/call` still in flight when the
957    /// input reaches EOF.
958    ///
959    /// The shipped 0.63.0 loop returned the instant stdin closed, without
960    /// joining the worker that owed the client its answer, so
961    /// `printf '<initialize>\n<tools/call>\n' | apr mcp` answered initialize,
962    /// exited 0, and silently dropped the tool result — indistinguishable
963    /// from a tool that produced no output.
964    #[cfg(feature = "native")]
965    #[test]
966    fn serve_stream_answers_tools_call_before_returning_on_eof() {
967        let responses = drive(format!("{INIT_LINE}\n{CALL_LINE}\n").as_bytes());
968
969        let call = find_id(&responses, 2).unwrap_or_else(|| {
970            panic!(
971                "tools/call response (id=2) was DROPPED at EOF; got {} response(s): {responses:?}",
972                responses.len()
973            )
974        });
975        assert!(
976            call.get("error").is_none(),
977            "tools/call must succeed, got {call:?}"
978        );
979        let text = call["result"]["content"][0]["text"]
980            .as_str()
981            .unwrap_or_else(|| panic!("missing content text in {call:?}"));
982        let payload: serde_json::Value =
983            serde_json::from_str(text).expect("apr.version payload is JSON");
984        assert_eq!(
985            payload["server"], "aprender-mcp",
986            "must be the real apr.version result, not an empty envelope"
987        );
988        assert!(
989            find_id(&responses, 1).is_some(),
990            "initialize still answered"
991        );
992    }
993
994    /// FALSIFY-MCP-010 (concurrency): several pipelined `tools/call` requests
995    /// must ALL be answered, not just the ones that happened to finish before
996    /// EOF.
997    #[cfg(feature = "native")]
998    #[test]
999    fn serve_stream_answers_every_pipelined_tools_call() {
1000        let mut input = format!("{INIT_LINE}\n");
1001        for id in 2..=6 {
1002            input.push_str(&format!(
1003                r#"{{"jsonrpc":"2.0","id":{id},"method":"tools/call","params":{{"name":"apr.version","arguments":{{}}}}}}"#
1004            ));
1005            input.push('\n');
1006        }
1007
1008        let responses = drive(input.as_bytes());
1009        for id in 1..=6 {
1010            assert!(
1011                find_id(&responses, id).is_some(),
1012                "id={id} unanswered; got {} of 6: {responses:?}",
1013                responses.len()
1014            );
1015        }
1016    }
1017
1018    /// A reader that replays `bytes` and then fails, so the read loop leaves
1019    /// through an `io::Error` instead of through EOF.
1020    #[cfg(feature = "native")]
1021    struct FailsAfterInput {
1022        bytes: Vec<u8>,
1023        pos: usize,
1024    }
1025
1026    #[cfg(feature = "native")]
1027    impl std::io::Read for FailsAfterInput {
1028        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1029            if self.pos == self.bytes.len() {
1030                return Err(std::io::Error::other("simulated stdin failure"));
1031            }
1032            let n = std::cmp::min(buf.len(), self.bytes.len() - self.pos);
1033            buf[..n].copy_from_slice(&self.bytes[self.pos..self.pos + n]);
1034            self.pos += n;
1035            Ok(n)
1036        }
1037    }
1038
1039    /// A writer that stalls for [`SLOW_WRITE`] on the line carrying `"id":2`,
1040    /// and records every completed write in a sink the test can read WITHOUT
1041    /// waiting on the server's own stdout mutex.
1042    ///
1043    /// The separate sink is the whole point: the worker holds the stdout mutex
1044    /// for the duration of its stalled write, so a test that inspected the
1045    /// stdout buffer directly would block until the worker finished and would
1046    /// then see the very response it was supposed to prove missing — green on
1047    /// the defect.
1048    #[cfg(feature = "native")]
1049    struct StallsOnId2 {
1050        sink: Arc<Mutex<Vec<u8>>>,
1051    }
1052
1053    #[cfg(feature = "native")]
1054    const SLOW_WRITE: std::time::Duration = std::time::Duration::from_secs(2);
1055
1056    #[cfg(feature = "native")]
1057    impl std::io::Write for StallsOnId2 {
1058        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
1059            if String::from_utf8_lossy(data).contains(r#""id":2"#) {
1060                std::thread::sleep(SLOW_WRITE);
1061            }
1062            self.sink
1063                .lock()
1064                .expect("sink mutex not poisoned")
1065                .extend_from_slice(data);
1066            Ok(data.len())
1067        }
1068
1069        fn flush(&mut self) -> std::io::Result<()> {
1070            Ok(())
1071        }
1072    }
1073
1074    /// FALSIFY-MCP-DRAIN-001 (#2608): EOF is not the only exit from the read
1075    /// loop, and the other exits owe the client the same answers.
1076    ///
1077    /// `serve_stream` drained on EOF only. Every `?` in the loop — a stdin
1078    /// read error, a stdout write error — returned straight past the drain and
1079    /// the process exited on top of workers that still owed responses. That is
1080    /// the identical protocol violation #2608 measured at EOF: a JSON-RPC
1081    /// request answered by neither a result nor an error.
1082    ///
1083    /// Determinism, not scheduling luck: the worker's own write stalls for two
1084    /// seconds. Without the drain `serve_stream` returns in microseconds with
1085    /// the id=2 bytes still unwritten, so the assertion below is RED by a
1086    /// two-second margin. (An ordering variant of the EOF falsifier was
1087    /// deleted from `tests/falsify_mcp_stdio_protocol.rs` for exactly the
1088    /// opposite reason: it stayed green on the defect.)
1089    ///
1090    /// No wall-clock value is ASSERTED here — the stall is the fixture, and
1091    /// the assertions are all about which bytes exist.
1092    #[cfg(feature = "native")]
1093    #[test]
1094    fn serve_stream_drains_in_flight_workers_when_the_read_loop_aborts() {
1095        let input = format!("{INIT_LINE}\n{CALL_LINE}\n");
1096        let reader = std::io::BufReader::new(FailsAfterInput {
1097            bytes: input.into_bytes(),
1098            pos: 0,
1099        });
1100        let sink = Arc::new(Mutex::new(Vec::<u8>::new()));
1101        let out = Arc::new(Mutex::new(StallsOnId2 {
1102            sink: Arc::clone(&sink),
1103        }));
1104        let mut server = AprMcpServer::new();
1105
1106        let outcome = server.serve_stream(reader, Arc::clone(&out));
1107
1108        assert!(
1109            outcome.is_err(),
1110            "the stdin failure must still be reported after the drain"
1111        );
1112        let written = {
1113            let guard = sink.lock().expect("sink mutex not poisoned");
1114            String::from_utf8_lossy(&guard).into_owned()
1115        };
1116        assert!(
1117            written.contains(r#""id":2"#),
1118            "the in-flight tools/call was answered by NEITHER a result NOR an error \
1119             when the read loop aborted; stdout was: {written:?}"
1120        );
1121        assert!(
1122            written.contains("aprender-mcp"),
1123            "the answer must be the real apr.version payload, not an empty envelope: {written:?}"
1124        );
1125        assert!(
1126            server.workers.is_empty(),
1127            "{} worker(s) were abandoned instead of joined",
1128            server.workers.len()
1129        );
1130    }
1131
1132    /// FALSIFY-MCP-011: an invalid UTF-8 byte is a malformed MESSAGE. It must
1133    /// cost exactly that one message — a -32700 — and the session must keep
1134    /// serving, matching how the loop already treats malformed JSON.
1135    ///
1136    /// The shipped loop propagated an `io::Error` out of `BufRead::lines()`
1137    /// and killed the process (exit 1), losing every later request. Here the
1138    /// same failure would surface as `serve_stream` returning `Err`, which
1139    /// `drive` turns into a panic.
1140    #[cfg(feature = "native")]
1141    #[test]
1142    fn serve_stream_survives_invalid_utf8_line() {
1143        let mut input: Vec<u8> = Vec::new();
1144        input.extend_from_slice(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#);
1145        input.push(b'\n');
1146        input.push(0xFF); // never valid UTF-8
1147        input.push(b'\n');
1148        input.extend_from_slice(br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#);
1149        input.push(b'\n');
1150
1151        let responses = drive(&input);
1152
1153        assert!(
1154            find_id(&responses, 1).is_some(),
1155            "request before the bad byte must be answered: {responses:?}"
1156        );
1157        let after = find_id(&responses, 2)
1158            .unwrap_or_else(|| panic!("request AFTER the bad byte was lost: {responses:?}"));
1159        assert!(
1160            after.get("error").is_none(),
1161            "request after the bad byte must be served normally, got {after:?}"
1162        );
1163        let parse_err = responses
1164            .iter()
1165            .find(|r| r["error"]["code"] == serde_json::json!(-32700))
1166            .unwrap_or_else(|| panic!("the bad line itself must be reported: {responses:?}"));
1167        assert!(
1168            parse_err["error"]["message"]
1169                .as_str()
1170                .unwrap_or_default()
1171                .contains("UTF-8"),
1172            "the -32700 must name the cause, got {parse_err:?}"
1173        );
1174    }
1175
1176    /// FALSIFY-MCP-011 (leading byte): a bad byte arriving before any valid
1177    /// request must not stop the session from ever starting.
1178    #[cfg(feature = "native")]
1179    #[test]
1180    fn serve_stream_survives_leading_invalid_utf8() {
1181        let mut input: Vec<u8> = vec![0x80, b'\n'];
1182        input.extend_from_slice(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#);
1183        input.push(b'\n');
1184
1185        let responses = drive(&input);
1186        assert!(
1187            find_id(&responses, 1).is_some(),
1188            "the request after a leading bad byte must be answered: {responses:?}"
1189        );
1190    }
1191
1192    /// The whole request-handling surface, over the real loop: negotiation,
1193    /// ping, Invalid-Request classification, batch diagnostics, and the
1194    /// -32700 case that must NOT regress.
1195    #[cfg(feature = "native")]
1196    #[test]
1197    fn serve_stream_protocol_surface_matches_jsonrpc_and_mcp() {
1198        let input = concat!(
1199            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
1200            "\n",
1201            r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#,
1202            "\n",
1203            r#"{"id":3,"method":"tools/list"}"#,
1204            "\n",
1205            r#"{"jsonrpc":"2.0","id":4}"#,
1206            "\n",
1207            r#"[{"jsonrpc":"2.0","id":5,"method":"tools/list"}]"#,
1208            "\n",
1209            r#"{not json"#,
1210            "\n",
1211            r#"{"jsonrpc":"2.0","id":7,"method":"tools/list"}"#,
1212            "\n",
1213        );
1214
1215        let responses = drive(input.as_bytes());
1216
1217        let init = find_id(&responses, 1).expect("initialize answered");
1218        assert!(
1219            init.get("error").is_none(),
1220            "a newer protocolVersion must not abort the handshake: {init:?}"
1221        );
1222        assert_eq!(init["result"]["protocolVersion"], crate::PROTOCOL_VERSION);
1223
1224        let pong = find_id(&responses, 2).expect("ping answered");
1225        assert_eq!(pong["result"], serde_json::json!({}), "ping must pong");
1226
1227        for id in [3, 4] {
1228            let resp = find_id(&responses, id).unwrap_or_else(|| {
1229                panic!("id={id} must be echoed on an Invalid Request: {responses:?}")
1230            });
1231            assert_eq!(
1232                resp["error"]["code"],
1233                serde_json::json!(-32600),
1234                "id={id} must be Invalid Request, not Parse error: {resp:?}"
1235            );
1236        }
1237
1238        let batch = responses
1239            .iter()
1240            .find(|r| {
1241                r["error"]["message"]
1242                    .as_str()
1243                    .is_some_and(|m| m.contains("batch"))
1244            })
1245            .unwrap_or_else(|| panic!("batch array must be diagnosed as such: {responses:?}"));
1246        assert_eq!(batch["error"]["code"], serde_json::json!(-32600));
1247
1248        assert!(
1249            responses
1250                .iter()
1251                .any(|r| r["error"]["code"] == serde_json::json!(-32700)),
1252            "`{{not json` must remain a Parse error: {responses:?}"
1253        );
1254        assert!(
1255            find_id(&responses, 7).is_some(),
1256            "the loop must keep serving after every malformed line: {responses:?}"
1257        );
1258    }
1259
1260    /// FALSIFY-MCP-009 over the real loop: a request with no id is a
1261    /// notification and MUST NOT be answered.
1262    #[cfg(feature = "native")]
1263    #[test]
1264    fn serve_stream_never_answers_a_notification() {
1265        let responses = drive(
1266            concat!(
1267                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
1268                "\n",
1269                r#"{"jsonrpc":"2.0","method":"tools/list"}"#,
1270                "\n",
1271                r#"{"jsonrpc":"2.0","id":9,"method":"ping"}"#,
1272                "\n",
1273            )
1274            .as_bytes(),
1275        );
1276
1277        assert_eq!(
1278            responses.len(),
1279            1,
1280            "only the id-bearing request may be answered: {responses:?}"
1281        );
1282        assert!(find_id(&responses, 9).is_some());
1283    }
1284
1285    /// FALSIFY-MCP-001: initialize returns protocolVersion "2024-11-05".
1286    #[test]
1287    fn initialize_returns_protocol_version() {
1288        let mut server = AprMcpServer::new();
1289        let req = make_request("initialize", serde_json::json!({}));
1290        let resp = server.handle_request(&req);
1291
1292        assert!(resp.error.is_none());
1293        let result = resp.result.expect("result present");
1294        assert_eq!(result["protocolVersion"], "2024-11-05");
1295        assert_eq!(result["serverInfo"]["name"], "aprender-mcp");
1296        assert!(result["capabilities"]["tools"].is_object());
1297    }
1298
1299    /// FALSIFY-MCP-002: tools/list returns every registered tool. The
1300    /// Phase-1 8-tool set (M2 subprocess wrappers + M3 `apr.finetune`) plus
1301    /// the `apr.version` M1 scaffold is what a conforming dispatcher now
1302    /// advertises; adding a new tool should fail this test until the contract
1303    /// YAML and codegen are updated in lockstep.
1304    #[test]
1305    fn tools_list_returns_registered_tools() {
1306        let mut server = AprMcpServer::new();
1307        let req = make_request("tools/list", serde_json::json!({}));
1308        let resp = server.handle_request(&req);
1309
1310        let result = resp.result.expect("result present");
1311        let tools = result["tools"].as_array().expect("tools array");
1312        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
1313        for expected in [
1314            "apr.version",
1315            "apr.validate",
1316            "apr.tensors",
1317            "apr.bench",
1318            "apr.qa",
1319            "apr.trace",
1320            "apr.run",
1321            "apr.serve",
1322            "apr.finetune",
1323        ] {
1324            assert!(names.contains(&expected), "{expected} registered");
1325        }
1326
1327        for tool in tools {
1328            assert_eq!(tool["inputSchema"]["type"], "object");
1329        }
1330    }
1331
1332    #[test]
1333    fn tools_call_version_returns_metadata() {
1334        let mut server = AprMcpServer::new();
1335        let req = make_request(
1336            "tools/call",
1337            serde_json::json!({ "name": "apr.version", "arguments": {} }),
1338        );
1339        let resp = server.handle_request(&req);
1340
1341        let result = resp.result.expect("result present");
1342        let text = result["content"][0]["text"].as_str().expect("text");
1343        let parsed: serde_json::Value = serde_json::from_str(text).expect("json");
1344        assert_eq!(parsed["server"], "aprender-mcp");
1345        assert_eq!(parsed["protocol_version"], "2024-11-05");
1346    }
1347
1348    #[test]
1349    fn unknown_method_returns_method_not_found() {
1350        let mut server = AprMcpServer::new();
1351        let req = make_request("tools/explode", serde_json::json!({}));
1352        let resp = server.handle_request(&req);
1353
1354        assert!(resp.result.is_none());
1355        let err = resp.error.expect("error present");
1356        assert_eq!(err.code, -32601);
1357    }
1358
1359    /// `apr.validate` without `model_path` must return `isError: true` via
1360    /// the argument-validation branch (no subprocess spawn).
1361    #[test]
1362    fn tools_call_validate_missing_model_path_is_error() {
1363        let mut server = AprMcpServer::new();
1364        let req = make_request(
1365            "tools/call",
1366            serde_json::json!({ "name": "apr.validate", "arguments": {} }),
1367        );
1368        let resp = server.handle_request(&req);
1369
1370        let result = resp.result.expect("result present");
1371        assert_eq!(result["isError"], true);
1372        let text = result["content"][0]["text"].as_str().expect("text");
1373        assert!(text.contains("model_path"));
1374    }
1375
1376    #[test]
1377    fn tools_call_unknown_tool_returns_is_error() {
1378        let mut server = AprMcpServer::new();
1379        let req = make_request(
1380            "tools/call",
1381            serde_json::json!({ "name": "apr.nonexistent" }),
1382        );
1383        let resp = server.handle_request(&req);
1384
1385        let result = resp.result.expect("result present");
1386        assert_eq!(result["isError"], true);
1387    }
1388
1389    #[test]
1390    fn tools_call_missing_name_returns_is_error() {
1391        let mut server = AprMcpServer::new();
1392        let req = make_request("tools/call", serde_json::json!({}));
1393        let resp = server.handle_request(&req);
1394
1395        let result = resp.result.expect("result present");
1396        assert_eq!(result["isError"], true);
1397    }
1398
1399    #[test]
1400    fn id_is_echoed_back() {
1401        let mut server = AprMcpServer::new();
1402        let req = JsonRpcRequest {
1403            jsonrpc: "2.0".to_string(),
1404            id: Some(serde_json::json!("req-42")),
1405            method: "initialize".to_string(),
1406            params: serde_json::json!({}),
1407        };
1408        let resp = server.handle_request(&req);
1409        assert_eq!(resp.id, Some(serde_json::json!("req-42")));
1410    }
1411
1412    /// FALSIFY-MCP-006 (unit): registering an id and then cancelling it
1413    /// signals the receiver and removes the entry.
1414    #[test]
1415    fn cancel_in_flight_signals_and_deregisters() {
1416        let server = AprMcpServer::new();
1417        let id = serde_json::json!(99);
1418        let rx = AprMcpServer::register_in_flight(&server.in_flight, id.clone());
1419
1420        let signalled = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
1421        assert!(signalled, "live id should signal");
1422        // Sender was dropped by cancel_in_flight (removed from the map), so
1423        // try_recv must see either the signal or a disconnected channel —
1424        // both prove the cancel reached the receiver side.
1425        let received = rx.try_recv();
1426        assert!(received.is_ok(), "cancel signal must be deliverable");
1427
1428        // Idempotent: second call is a no-op.
1429        let signalled_again = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
1430        assert!(
1431            !signalled_again,
1432            "cancelling an already-removed id is a no-op"
1433        );
1434    }
1435
1436    /// FALSIFY-MCP-007: a client proposing a version we do not speak must get
1437    /// the version we DO speak, not a handshake-aborting error. Claude Code
1438    /// and Cursor propose 2025-03-26 / 2025-06-18; under the old -32602 gate
1439    /// neither could ever connect.
1440    #[test]
1441    fn initialize_negotiates_down_instead_of_erroring() {
1442        for proposed in ["2025-06-18", "2025-03-26", "2024-10-07", "latest", ""] {
1443            let mut server = AprMcpServer::new();
1444            let req = make_request(
1445                "initialize",
1446                serde_json::json!({ "protocolVersion": proposed }),
1447            );
1448            let resp = server.handle_request(&req);
1449
1450            assert!(
1451                resp.error.is_none(),
1452                "proposing {proposed:?} must not abort the handshake, got {:?}",
1453                resp.error
1454            );
1455            let result = resp.result.expect("result present");
1456            assert_eq!(
1457                result["protocolVersion"],
1458                crate::PROTOCOL_VERSION,
1459                "server must answer with the version it actually speaks"
1460            );
1461        }
1462    }
1463
1464    /// A non-string `protocolVersion` must not be treated as a proposal we
1465    /// somehow honoured — the reply still carries our version.
1466    #[test]
1467    fn initialize_ignores_non_string_protocol_version() {
1468        let mut server = AprMcpServer::new();
1469        let req = make_request("initialize", serde_json::json!({ "protocolVersion": 2025 }));
1470        let resp = server.handle_request(&req);
1471        assert!(resp.error.is_none());
1472        let result = resp.result.expect("result present");
1473        assert_eq!(result["protocolVersion"], crate::PROTOCOL_VERSION);
1474    }
1475
1476    /// `ping` is MCP base protocol: an empty result, not -32601. A keepalive
1477    /// client reads an error (or silence) as a dead server and restarts it.
1478    #[test]
1479    fn ping_returns_empty_result() {
1480        let mut server = AprMcpServer::new();
1481        let req = make_request("ping", serde_json::json!({}));
1482        let resp = server.handle_request(&req);
1483
1484        assert!(
1485            resp.error.is_none(),
1486            "ping must not error: {:?}",
1487            resp.error
1488        );
1489        assert_eq!(resp.result, Some(serde_json::json!({})));
1490        assert_eq!(resp.id, Some(serde_json::json!(1)), "id echoed");
1491    }
1492
1493    /// FALSIFY-MCP-DRAIN-002 (#2608): a tool that panics must still answer.
1494    ///
1495    /// The worker built a response only on the success path, and
1496    /// `join_workers` swallows the panic, so a panicking tool left its request
1497    /// answered by neither a result nor an error — the client waits forever
1498    /// while the server exits 0. The panic message reaches stderr and is
1499    /// expected noise in this test's output.
1500    #[cfg(feature = "native")]
1501    #[test]
1502    fn worker_response_turns_a_tool_panic_into_32603_for_the_same_id() {
1503        let resp = AprMcpServer::worker_response(&serde_json::json!(7), || {
1504            panic!("tool exploded while dispatching")
1505        });
1506
1507        assert_eq!(
1508            resp.id,
1509            Some(serde_json::json!(7)),
1510            "the id the client must correlate on has to survive the panic: {resp:?}"
1511        );
1512        assert!(
1513            resp.result.is_none(),
1514            "a panic must not also produce a result: {resp:?}"
1515        );
1516        let err = resp
1517            .error
1518            .expect("a panicking tool must produce an ERROR, never silence");
1519        assert_eq!(
1520            err.code, -32603,
1521            "a tool panic is an Internal error: {err:?}"
1522        );
1523        assert!(
1524            err.message.contains("panicked"),
1525            "the -32603 must name the cause: {err:?}"
1526        );
1527        assert!(
1528            err.message.contains("tool exploded while dispatching"),
1529            "the panic message itself must reach the client: {err:?}"
1530        );
1531    }
1532
1533    /// The other direction of FALSIFY-MCP-DRAIN-002: the panic guard must not
1534    /// turn ordinary results into errors. Without this, "always answer
1535    /// -32603" would satisfy the test above.
1536    #[cfg(feature = "native")]
1537    #[test]
1538    fn worker_response_passes_a_normal_tool_result_through_unchanged() {
1539        let resp = AprMcpServer::worker_response(&serde_json::json!("abc"), || {
1540            ToolCallResult::success("payload".to_string())
1541        });
1542
1543        assert_eq!(resp.id, Some(serde_json::json!("abc")), "id echoed");
1544        assert!(resp.error.is_none(), "no error on the happy path: {resp:?}");
1545        let result = resp.result.expect("result present");
1546        assert_eq!(
1547            result["content"][0]["text"], "payload",
1548            "the tool's own payload must reach the client verbatim: {result:?}"
1549        );
1550    }
1551
1552    /// A tool dispatch that panics. Every registered tool is a subprocess
1553    /// wrapper or a pure metadata read, so none can be made to panic from a
1554    /// `tools/call` argument; this stands in for the tool that does.
1555    ///
1556    /// The marker string is unique so the assertion below cannot be satisfied
1557    /// by some other error the server might produce for id=2.
1558    #[cfg(feature = "native")]
1559    fn panicking_dispatch(
1560        _index: &crate::tools::ToolIndex,
1561        _args: &serde_json::Value,
1562        _cancel_rx: &mpsc::Receiver<()>,
1563        _sink: Option<&NotificationSink>,
1564        _progress_token: Option<serde_json::Value>,
1565    ) -> ToolCallResult {
1566        panic!("PANIC-PROBE-2608 exploded inside tool dispatch")
1567    }
1568
1569    /// FALSIFY-MCP-DRAIN-005 (#2608): the panic guard must be REACHED.
1570    ///
1571    /// [`AprMcpServer::worker_response`] can be perfectly correct while the
1572    /// server never calls it — that is the whole defect class this PR is
1573    /// about, and the two `worker_response_*` tests above exercise the helper
1574    /// directly, so both stay green when the call site is deleted. This test
1575    /// never mentions `worker_response`: it feeds a `tools/call` into
1576    /// [`AprMcpServer::serve_stream`] and reads what came out of the client's
1577    /// end of stdout, the same surface #2608 measured. The route under test is
1578    /// `serve_stream` → `read_loop` → `route_stdio_message` →
1579    /// `spawn_tools_call_worker` → the worker thread.
1580    ///
1581    /// Mutation (the pre-#2608 shape — dispatch called BESIDE the guard rather
1582    /// than inside it):
1583    ///
1584    /// ```ignore
1585    /// let result = dispatch(&params, &cancel_rx, sink_ref, progress_token);
1586    /// let resp = JsonRpcResponse::success(Some(id_for_worker.clone()), ...);
1587    /// ```
1588    ///
1589    /// The worker then unwinds, `join_workers` swallows the panic exactly as
1590    /// documented, and nothing is ever written for id=2 — this test fails on
1591    /// "answered by NEITHER a result NOR an error", while
1592    /// `worker_response_turns_a_tool_panic_into_32603_for_the_same_id` stays
1593    /// green. That asymmetry is what makes this a wiring guard and not a
1594    /// restatement of the helper.
1595    ///
1596    /// The worker's panic message reaches stderr; it is expected noise.
1597    #[cfg(feature = "native")]
1598    #[test]
1599    fn a_panicking_tool_is_answered_through_the_real_stdio_path() {
1600        let out = Arc::new(Mutex::new(Vec::<u8>::new()));
1601        let mut server = AprMcpServer::new();
1602        server.worker_dispatch = panicking_dispatch;
1603
1604        let outcome = server.serve_stream(
1605            std::io::Cursor::new(format!("{INIT_LINE}\n{CALL_LINE}\n").into_bytes()),
1606            Arc::clone(&out),
1607        );
1608
1609        assert!(
1610            outcome.is_ok(),
1611            "a panicking TOOL must not take the SESSION down: {outcome:?}"
1612        );
1613        let responses = parse_written_lines(&out);
1614        let call = find_id(&responses, 2).unwrap_or_else(|| {
1615            panic!(
1616                "the tools/call whose tool panicked was answered by NEITHER a result NOR an \
1617                 error — the worker never routed through the panic guard; got {} response(s): \
1618                 {responses:?}",
1619                responses.len()
1620            )
1621        });
1622        assert!(
1623            call.get("result").is_none(),
1624            "a panic must not also produce a result: {call:?}"
1625        );
1626        assert_eq!(
1627            call["error"]["code"],
1628            serde_json::json!(-32603),
1629            "a tool panic is an Internal error for the SAME id: {call:?}"
1630        );
1631        let message = call["error"]["message"]
1632            .as_str()
1633            .unwrap_or_else(|| panic!("error message must be a string: {call:?}"));
1634        assert!(
1635            message.contains("PANIC-PROBE-2608 exploded inside tool dispatch"),
1636            "the panic's own message must reach the client, not a generic envelope: {message:?}"
1637        );
1638        assert!(
1639            find_id(&responses, 1).is_some(),
1640            "initialize must still be answered: {responses:?}"
1641        );
1642        assert!(
1643            server.workers.is_empty(),
1644            "{} worker(s) were abandoned instead of joined",
1645            server.workers.len()
1646        );
1647    }
1648
1649    /// The seam FALSIFY-MCP-DRAIN-005 uses must not be able to hide a stubbed
1650    /// production default: a server nobody touched dispatches through the real
1651    /// inventory-backed tool dispatcher.
1652    #[cfg(feature = "native")]
1653    #[test]
1654    fn default_worker_dispatch_is_the_real_tool_dispatcher() {
1655        let server = AprMcpServer::new();
1656        let (_cancel_tx, cancel_rx) = mpsc::channel();
1657
1658        let result = (server.worker_dispatch)(
1659            &server.index,
1660            &serde_json::json!({ "name": "apr.version", "arguments": {} }),
1661            &cancel_rx,
1662            None,
1663            None,
1664        );
1665
1666        assert!(
1667            result.is_error.is_none(),
1668            "the real dispatcher answers apr.version: {result:?}"
1669        );
1670        let payload: serde_json::Value = serde_json::from_str(&result.content[0].text)
1671            .unwrap_or_else(|e| panic!("apr.version payload is JSON: {e}"));
1672        assert_eq!(
1673            payload["server"], "aprender-mcp",
1674            "the default must be the real dispatcher, not a stub: {payload:?}"
1675        );
1676    }
1677
1678    /// FALSIFY-MCP-012: valid JSON that is not a valid Request object is
1679    /// -32600 Invalid Request with the id echoed — NOT -32700 with id null.
1680    /// The server already got this right for a WRONG jsonrpc value, so the
1681    /// missing-field path disagreed with its own neighbour.
1682    #[test]
1683    fn missing_required_field_is_invalid_request_with_id_echoed() {
1684        for line in [
1685            r#"{"id":1,"method":"tools/list"}"#,
1686            r#"{"jsonrpc":"2.0","id":1}"#,
1687            r#"{"jsonrpc":2.0,"id":1,"method":"tools/list"}"#,
1688            r#"{"jsonrpc":"2.0","id":1,"method":42}"#,
1689        ] {
1690            let resp = parse_incoming(line).expect_err("must be rejected");
1691            let err = resp.error.as_ref().expect("error present");
1692            assert_eq!(
1693                err.code, -32600,
1694                "{line} must be Invalid Request, got {err:?}"
1695            );
1696            assert_eq!(
1697                resp.id,
1698                Some(serde_json::json!(1)),
1699                "{line} must echo the client's id so it can correlate"
1700            );
1701        }
1702    }
1703
1704    /// Genuinely malformed JSON must STAY -32700 — the fix above must not
1705    /// swallow the case the code already handled correctly.
1706    #[test]
1707    fn malformed_json_is_still_parse_error() {
1708        for line in [
1709            r#"{not json"#,
1710            r#"{"jsonrpc":"2.0","id":1,"method":"tools/li"#,
1711        ] {
1712            let resp = parse_incoming(line).expect_err("must be rejected");
1713            let err = resp.error.as_ref().expect("error present");
1714            assert_eq!(err.code, -32700, "{line} must remain a Parse error");
1715        }
1716    }
1717
1718    /// A batch array must be diagnosed as a batch array. The old message was
1719    /// serde's "invalid type: map, expected a string at line 1 column 1",
1720    /// which names neither batching nor arrays.
1721    #[test]
1722    fn batch_array_is_diagnosed_as_unsupported_batching() {
1723        let line = r#"[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":"tools/list"}]"#;
1724        let resp = parse_incoming(line).expect_err("batch must be rejected");
1725        let err = resp.error.as_ref().expect("error present");
1726        assert_eq!(err.code, -32600);
1727        assert!(
1728            err.message.contains("batch"),
1729            "message must name batching, got: {}",
1730            err.message
1731        );
1732        assert!(
1733            !err.message.contains("expected a string"),
1734            "must not leak serde's field-level error, got: {}",
1735            err.message
1736        );
1737    }
1738
1739    #[test]
1740    fn non_object_request_is_invalid_request() {
1741        for line in ["42", r#""hello""#, "null", "true"] {
1742            let resp = parse_incoming(line).expect_err("must be rejected");
1743            let err = resp.error.as_ref().expect("error present");
1744            assert_eq!(err.code, -32600, "{line} must be Invalid Request");
1745        }
1746    }
1747
1748    /// Happy path: a well-formed request survives the new shape validation
1749    /// with every field intact, including an absent `params`.
1750    #[test]
1751    fn well_formed_request_parses_unchanged() {
1752        let req = parse_incoming(r#"{"jsonrpc":"2.0","id":"abc","method":"tools/list"}"#)
1753            .expect("well-formed request must parse");
1754        assert_eq!(req.jsonrpc, "2.0");
1755        assert_eq!(req.method, "tools/list");
1756        assert_eq!(req.id, Some(serde_json::json!("abc")));
1757        assert_eq!(req.params, serde_json::Value::Null);
1758
1759        let with_params = parse_incoming(
1760            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"apr.version"}}"#,
1761        )
1762        .expect("params must round-trip");
1763        assert_eq!(with_params.params["name"], "apr.version");
1764    }
1765
1766    /// FALSIFY-MCP-009 must survive the rewrite: a null or absent id still
1767    /// means "notification", which `route_stdio_message` relies on to stay
1768    /// silent.
1769    #[test]
1770    fn null_and_absent_id_both_parse_as_notification() {
1771        let absent = parse_incoming(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
1772            .expect("parse");
1773        assert!(absent.id.is_none());
1774        let null_id =
1775            parse_incoming(r#"{"jsonrpc":"2.0","id":null,"method":"tools/list"}"#).expect("parse");
1776        assert!(null_id.id.is_none(), "a null id is not an id");
1777    }
1778
1779    /// FALSIFY-MCP-006 (unit): cancelling an unknown id is a safe no-op.
1780    #[test]
1781    fn cancel_unknown_id_is_noop() {
1782        let server = AprMcpServer::new();
1783        let id = serde_json::json!("never-registered");
1784        let signalled = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
1785        assert!(!signalled);
1786    }
1787}