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