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(¶ms);
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, ¬if);
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(¶ms, &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(¶ms, &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}