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