Skip to main content

aprender_mcp/
server.rs

1//! `AprMcpServer` — JSON-RPC 2.0 dispatcher for aprender MCP tools.
2//!
3//! # Cancellation model (FALSIFY-MCP-006)
4//!
5//! `tools/call` requests that target `apr.run` are dispatched on a worker
6//! thread so the main stdio loop can continue reading and honour
7//! `notifications/cancelled`. Each in-flight call registers a [`CancelHandle`]
8//! in [`AprMcpServer::in_flight`], keyed by request id. A matching
9//! `notifications/cancelled` signals the worker's cancel channel; the worker
10//! then SIGTERMs the spawned `apr` subprocess, waits
11//! [`crate::tools::subprocess::CANCEL_GRACE_MS`], and SIGKILLs if still alive.
12//!
13//! Non-cancellable tool calls still run on a worker (so future concurrent
14//! calls don't block notifications/cancelled routing) but their cancel
15//! channels are never signalled. `initialize`, `tools/list`, and other
16//! fast synchronous methods dispatch inline on the main thread.
17
18#![allow(clippy::disallowed_methods)] // serde_json::json! macro expands to .unwrap() internally
19
20use crate::tools;
21use crate::types::{
22    JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ToolCallResult, ToolDefinition,
23};
24use std::collections::HashMap;
25use std::sync::mpsc::{self, Sender};
26use std::sync::{Arc, Mutex};
27
28/// Callback used by tools to emit `notifications/progress` messages back to
29/// the MCP client while a long-running `tools/call` is still in flight.
30///
31/// FALSIFY-MCP-PROGRESS-001: in stdio mode the dispatcher passes a sink that
32/// writes each notification as one JSON line to the shared stdout handle
33/// (guarded by the same mutex as final responses). In-process tests use an
34/// `Arc<Mutex<Vec<_>>>`-backed sink to assert the outgoing wire format.
35///
36/// Must be `Send` because the sink is moved into the worker thread that
37/// `run_stdio` spawns for every `tools/call`.
38pub type NotificationSink = Box<dyn Fn(JsonRpcNotification) + Send + Sync>;
39
40/// Per-request cancellation record held in [`AprMcpServer::in_flight`].
41///
42/// Only `apr.run` currently honours cancellation. Entries for other tools
43/// are still registered (so a stray `notifications/cancelled` doesn't log
44/// a warning) but their senders are never used.
45#[derive(Debug)]
46pub struct CancelHandle {
47    /// Sender side of the worker's cancel mpsc. `send(())` causes the
48    /// subprocess poll loop to SIGTERM its child.
49    pub cancel_tx: Sender<()>,
50}
51
52/// Map of in-flight `tools/call` requests keyed by JSON-RPC id.
53///
54/// The id is stored as a raw `serde_json::Value` because the MCP spec
55/// permits both integer and string ids.
56type InFlight = Arc<Mutex<HashMap<serde_json::Value, CancelHandle>>>;
57
58/// MCP server exposing the `apr` CLI as tools.
59///
60/// M1: `initialize`, `tools/list`, `tools/call` with `apr.version`.
61/// M3: `notifications/cancelled` routed to in-flight `apr.run` workers.
62#[derive(Debug, Default)]
63pub struct AprMcpServer {
64    in_flight: InFlight,
65}
66
67impl AprMcpServer {
68    /// Construct a new server.
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Dispatch a single JSON-RPC request synchronously.
75    ///
76    /// This is the in-process test entry point. It does NOT exercise the
77    /// threading / cancellation machinery — `apr.run` runs inline with a
78    /// dummy never-firing cancel receiver and NO notification sink is
79    /// attached, so `apr.finetune` silently falls back to its synchronous
80    /// path even if the request carries `params._meta.progressToken`. Use
81    /// [`Self::run_stdio`] for the full M3 dispatcher or
82    /// [`Self::handle_request_with_sink`] to drive FALSIFY-MCP-PROGRESS-001
83    /// in tests.
84    ///
85    /// The dispatcher enforces two protocol-level invariants before routing:
86    /// FALSIFY-MCP-005 (`jsonrpc` must be exactly `"2.0"` or the response is
87    /// `-32600 Invalid Request`) and FALSIFY-MCP-007 (an `initialize` whose
88    /// `params.protocolVersion` mismatches ours returns `-32602 Invalid Params`
89    /// instead of advancing to tools/list).
90    #[must_use]
91    pub fn handle_request(&mut self, request: &JsonRpcRequest) -> JsonRpcResponse {
92        if request.jsonrpc != "2.0" {
93            return JsonRpcResponse::error(
94                request.id.clone(),
95                -32600,
96                format!(
97                    "Invalid Request: jsonrpc must be \"2.0\", got \"{}\"",
98                    request.jsonrpc
99                ),
100            );
101        }
102
103        match request.method.as_str() {
104            "initialize" => self.handle_initialize(request),
105            "tools/list" => self.handle_tools_list(request),
106            "tools/call" => self.handle_tools_call_sync(request),
107            other => JsonRpcResponse::error(
108                request.id.clone(),
109                -32601,
110                format!("Method not found: {other}"),
111            ),
112        }
113    }
114
115    fn handle_initialize(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
116        // FALSIFY-MCP-007: if the client advertises a protocolVersion, it must
117        // match ours. Missing field is permitted (some clients omit it on the
118        // very first handshake); only a *mismatch* is rejected.
119        if let Some(client_version) = request
120            .params
121            .get("protocolVersion")
122            .and_then(|v| v.as_str())
123        {
124            if client_version != crate::PROTOCOL_VERSION {
125                return JsonRpcResponse::error(
126                    request.id.clone(),
127                    -32602,
128                    format!(
129                        "Unsupported protocolVersion: client requested \"{}\", server speaks \"{}\"",
130                        client_version,
131                        crate::PROTOCOL_VERSION
132                    ),
133                );
134            }
135        }
136
137        JsonRpcResponse::success(
138            request.id.clone(),
139            serde_json::json!({
140                "protocolVersion": crate::PROTOCOL_VERSION,
141                "capabilities": {
142                    "tools": { "listChanged": false }
143                },
144                "serverInfo": {
145                    "name": crate::SERVER_NAME,
146                    "version": env!("CARGO_PKG_VERSION"),
147                },
148            }),
149        )
150    }
151
152    fn handle_tools_list(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
153        let tools: Vec<ToolDefinition> = self.tool_definitions();
154        JsonRpcResponse::success(request.id.clone(), serde_json::json!({ "tools": tools }))
155    }
156
157    /// Synchronous fallback used by [`Self::handle_request`]. `apr.run`
158    /// runs with a never-firing cancel receiver — cancellation is only
159    /// wired by the stdio loop in [`Self::run_stdio`]. No notifications are
160    /// emitted from this path.
161    fn handle_tools_call_sync(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
162        let (_tx, rx) = mpsc::channel::<()>();
163        let result = dispatch_tool_call(&request.params, &rx, None);
164        JsonRpcResponse::success(
165            request.id.clone(),
166            serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
167        )
168    }
169
170    /// Dispatch one request with an explicit notification sink (test entry
171    /// point for FALSIFY-MCP-PROGRESS-001).
172    ///
173    /// The sink is only exercised for `tools/call` dispatches where
174    /// (a) the client supplied `params._meta.progressToken` on the original
175    /// request AND (b) the target tool supports progress streaming
176    /// (currently `apr.finetune` and `apr.run`). Other methods ignore the
177    /// sink.
178    ///
179    /// `handle_request_with_sink` returns `None` for notifications (methods
180    /// prefixed with `notifications/`) because notifications have no id and
181    /// MUST NOT receive a response per JSON-RPC 2.0. All other methods
182    /// return `Some(response)`.
183    #[must_use]
184    pub fn handle_request_with_sink(
185        &mut self,
186        request: &JsonRpcRequest,
187        sink: &NotificationSink,
188    ) -> Option<JsonRpcResponse> {
189        if request.jsonrpc != "2.0" {
190            return Some(JsonRpcResponse::error(
191                request.id.clone(),
192                -32600,
193                format!(
194                    "Invalid Request: jsonrpc must be \"2.0\", got \"{}\"",
195                    request.jsonrpc
196                ),
197            ));
198        }
199
200        if request.method.starts_with("notifications/") {
201            return None;
202        }
203
204        if request.method != "tools/call" {
205            return Some(self.handle_request(request));
206        }
207
208        let progress_token = extract_progress_token(&request.params);
209        let (_tx, rx) = mpsc::channel::<()>();
210        let sink_for_dispatch = progress_token.as_ref().map(|_| sink);
211        let result =
212            dispatch_tool_call_with_sink(&request.params, &rx, sink_for_dispatch, progress_token);
213        Some(JsonRpcResponse::success(
214            request.id.clone(),
215            serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
216        ))
217    }
218
219    /// All tool definitions registered on this server.
220    #[must_use]
221    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
222        vec![
223            tools::version_tool_definition(),
224            tools::validate_tool_definition(),
225            tools::tensors_tool_definition(),
226            tools::bench_tool_definition(),
227            tools::qa_tool_definition(),
228            tools::trace_tool_definition(),
229            tools::run_tool_definition(),
230            tools::serve_tool_definition(),
231            tools::finetune_tool_definition(),
232        ]
233    }
234
235    /// Register a new in-flight request and return its cancel receiver.
236    ///
237    /// Exposed for testing the cancellation routing without spawning a real
238    /// worker. Production code calls this from [`Self::run_stdio`].
239    #[must_use]
240    pub fn register_in_flight(in_flight: &InFlight, id: serde_json::Value) -> mpsc::Receiver<()> {
241        let (tx, rx) = mpsc::channel::<()>();
242        let mut guard = in_flight
243            .lock()
244            .expect("in_flight mutex not poisoned during register");
245        guard.insert(id, CancelHandle { cancel_tx: tx });
246        rx
247    }
248
249    /// Route a `notifications/cancelled` to the matching in-flight request.
250    ///
251    /// Idempotent: repeated cancels for the same id after the first are
252    /// silently dropped. References to completed / unknown ids are no-ops.
253    /// Returns `true` iff a live handle was signalled.
254    pub fn cancel_in_flight(in_flight: &InFlight, id: &serde_json::Value) -> bool {
255        let mut guard = in_flight
256            .lock()
257            .expect("in_flight mutex not poisoned during cancel");
258        if let Some(handle) = guard.remove(id) {
259            // Best-effort: if the worker already completed and dropped its
260            // receiver, the send fails silently — exactly the no-op we want.
261            let _ = handle.cancel_tx.send(());
262            true
263        } else {
264            false
265        }
266    }
267
268    /// Deregister an in-flight id after its worker finishes. Safe to call
269    /// even if the id was already removed by a concurrent cancel.
270    fn deregister_in_flight(in_flight: &InFlight, id: &serde_json::Value) {
271        if let Ok(mut guard) = in_flight.lock() {
272            guard.remove(id);
273        }
274    }
275
276    /// Run the server over stdio (blocking).
277    ///
278    /// Reads one JSON-RPC message per line from stdin. `initialize`,
279    /// `tools/list`, and unknown methods dispatch inline. `tools/call`
280    /// spawns a worker thread so a subsequent `notifications/cancelled`
281    /// message can flow through the main loop and signal the worker's
282    /// cancel channel. Workers write their responses directly to stdout
283    /// (guarded by a mutex) so the main loop never has to wait on them.
284    ///
285    /// # Errors
286    /// Returns an error if stdin/stdout I/O fails.
287    #[cfg(feature = "native")]
288    pub fn run_stdio(&mut self) -> anyhow::Result<()> {
289        use std::io::{self, BufRead};
290
291        let stdin = io::stdin();
292        let stdout = Arc::new(Mutex::new(io::stdout()));
293
294        for line in stdin.lock().lines() {
295            let line = line?;
296            if line.trim().is_empty() {
297                continue;
298            }
299
300            let parsed: Result<JsonRpcRequest, _> = serde_json::from_str(&line);
301            match parsed {
302                Ok(req) => self.route_stdio_message(req, &stdout)?,
303                Err(e) => {
304                    let resp = JsonRpcResponse::error(None, -32700, format!("Parse error: {e}"));
305                    write_response(&stdout, &resp)?;
306                }
307            }
308        }
309
310        Ok(())
311    }
312
313    /// Dispatch one parsed request within the stdio loop. Separated from
314    /// [`Self::run_stdio`] for testability.
315    #[cfg(feature = "native")]
316    fn route_stdio_message(
317        &mut self,
318        req: JsonRpcRequest,
319        stdout: &Arc<Mutex<std::io::Stdout>>,
320    ) -> anyhow::Result<()> {
321        // FALSIFY-MCP-005: jsonrpc field gate runs before method dispatch.
322        if req.jsonrpc != "2.0" {
323            let resp = JsonRpcResponse::error(
324                req.id.clone(),
325                -32600,
326                format!(
327                    "Invalid Request: jsonrpc must be \"2.0\", got \"{}\"",
328                    req.jsonrpc
329                ),
330            );
331            return write_response(stdout, &resp);
332        }
333
334        match req.method.as_str() {
335            // Notifications have no `id` and MUST NOT receive a response.
336            "notifications/cancelled" => {
337                if let Some(request_id) = req.params.get("requestId").cloned() {
338                    let _ = Self::cancel_in_flight(&self.in_flight, &request_id);
339                }
340                Ok(())
341            }
342            "notifications/initialized" => {
343                // Client handshake ack — no response, no state change.
344                Ok(())
345            }
346            "tools/call" => self.spawn_tools_call_worker(req, stdout),
347            // Fast inline paths.
348            _ => {
349                let resp = self.handle_request(&req);
350                write_response(stdout, &resp)
351            }
352        }
353    }
354
355    #[cfg(feature = "native")]
356    fn spawn_tools_call_worker(
357        &mut self,
358        req: JsonRpcRequest,
359        stdout: &Arc<Mutex<std::io::Stdout>>,
360    ) -> anyhow::Result<()> {
361        // Notifications would arrive with id = None; tools/call must have
362        // an id per JSON-RPC. Defensive: if it's missing, respond inline
363        // with an error so the client sees the failure immediately.
364        let Some(id) = req.id.clone() else {
365            let resp =
366                JsonRpcResponse::error(None, -32600, "Invalid Request: tools/call requires an id");
367            return write_response(stdout, &resp);
368        };
369
370        let cancel_rx = Self::register_in_flight(&self.in_flight, id.clone());
371        let stdout_clone = Arc::clone(stdout);
372        let in_flight_clone = Arc::clone(&self.in_flight);
373        let params = req.params.clone();
374        let id_for_worker = id.clone();
375        let progress_token = extract_progress_token(&params);
376
377        // Build a stdout-backed notification sink for this worker. The sink
378        // shares the response stdout mutex so progress lines and the final
379        // response can never interleave. Per MCP spec the sink is only
380        // wired when the client advertised a progressToken.
381        let sink_stdout = Arc::clone(stdout);
382        let sink: NotificationSink = Box::new(move |notif| {
383            // Best-effort: a broken stdout means the client disconnected.
384            let _ = write_notification(&sink_stdout, &notif);
385        });
386
387        // Thread spawn is infallible here in practice, but propagate the
388        // error rather than unwrapping so we stay in the "no panics" lane.
389        let builder = std::thread::Builder::new().name(format!("apr-mcp-call-{id}"));
390        let spawn_result = builder.spawn(move || {
391            let sink_ref = progress_token.as_ref().map(|_| &sink);
392            let result =
393                dispatch_tool_call_with_sink(&params, &cancel_rx, sink_ref, progress_token);
394            let resp = JsonRpcResponse::success(
395                Some(id_for_worker.clone()),
396                serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
397            );
398            // Best-effort: a broken stdout means the client disconnected,
399            // which we can't recover from anyway.
400            let _ = write_response(&stdout_clone, &resp);
401            Self::deregister_in_flight(&in_flight_clone, &id_for_worker);
402        });
403
404        match spawn_result {
405            Ok(_handle) => Ok(()),
406            Err(e) => {
407                // Failed to spawn — clean up the registry entry we just
408                // inserted and report the failure inline.
409                Self::deregister_in_flight(&self.in_flight, &id);
410                let resp = JsonRpcResponse::error(
411                    Some(id),
412                    -32603,
413                    format!("Internal error: failed to spawn worker thread: {e}"),
414                );
415                write_response(stdout, &resp)
416            }
417        }
418    }
419
420    /// Handle for tests that want to inspect the in-flight registry.
421    #[must_use]
422    pub fn in_flight_handle(&self) -> InFlight {
423        Arc::clone(&self.in_flight)
424    }
425}
426
427/// Shared tool-call dispatch logic used by both the sync and stdio paths.
428///
429/// `cancel_rx` is forwarded to `apr.run` only; the other tools ignore it.
430/// Callers that never need progress streaming can keep using this wrapper;
431/// the [`dispatch_tool_call_with_sink`] variant exposes the
432/// FALSIFY-MCP-PROGRESS-001 path.
433fn dispatch_tool_call(
434    params: &serde_json::Value,
435    cancel_rx: &mpsc::Receiver<()>,
436    sink: Option<&NotificationSink>,
437) -> ToolCallResult {
438    dispatch_tool_call_with_sink(params, cancel_rx, sink, None)
439}
440
441/// Full dispatch variant with optional `NotificationSink` + `progressToken`.
442///
443/// FALSIFY-MCP-PROGRESS-001 / FALSIFY-MCP-PROGRESS-002: when `sink` and
444/// `progress_token` are both `Some`, tools that support streaming
445/// (`apr.finetune` and `apr.run`) forward each stdout line as a
446/// `notifications/progress` message via `sink` before returning the final
447/// `ToolCallResult`. Tools that don't support streaming ignore the sink and
448/// run synchronously.
449fn dispatch_tool_call_with_sink(
450    params: &serde_json::Value,
451    cancel_rx: &mpsc::Receiver<()>,
452    sink: Option<&NotificationSink>,
453    progress_token: Option<serde_json::Value>,
454) -> ToolCallResult {
455    let name = params.get("name").and_then(|v| v.as_str());
456    let arguments = params
457        .get("arguments")
458        .cloned()
459        .unwrap_or_else(|| serde_json::json!({}));
460
461    match name {
462        Some(tools::version::NAME) => tools::version::call(&arguments),
463        Some(tools::validate::NAME) => tools::validate::call(&arguments),
464        Some(tools::tensors::NAME) => tools::tensors::call(&arguments),
465        Some(tools::bench::NAME) => tools::bench::call(&arguments),
466        Some(tools::qa::NAME) => tools::qa::call(&arguments),
467        Some(tools::trace::NAME) => tools::trace::call(&arguments),
468        Some(tools::run::NAME) => {
469            // FALSIFY-MCP-PROGRESS-002: pass sink + progressToken so that
470            // when the client requested progress, apr.run streams via
471            // `apr run --stream` and forwards each NDJSON line as
472            // notifications/progress. Without a progressToken the call
473            // falls back to the cancellable sync path.
474            tools::run::call_with_sink(&arguments, cancel_rx, sink, progress_token)
475        }
476        Some(tools::serve::NAME) => tools::serve::call(&arguments),
477        Some(tools::finetune::NAME) => {
478            tools::finetune::call_with_sink(&arguments, sink, progress_token)
479        }
480        Some(other) => ToolCallResult::error(format!("Unknown tool: {other}")),
481        None => ToolCallResult::error("Missing tool name"),
482    }
483}
484
485/// Pull `params._meta.progressToken` out of a `tools/call` request. Returns
486/// `None` when the field is absent — per MCP 2024-11-05 the server MUST NOT
487/// emit progress notifications in that case.
488fn extract_progress_token(params: &serde_json::Value) -> Option<serde_json::Value> {
489    params
490        .get("_meta")
491        .and_then(|m| m.get("progressToken"))
492        .cloned()
493}
494
495#[cfg(feature = "native")]
496fn write_response(
497    stdout: &Arc<Mutex<std::io::Stdout>>,
498    resp: &JsonRpcResponse,
499) -> anyhow::Result<()> {
500    use std::io::Write;
501
502    let json = serde_json::to_string(resp)?;
503    let mut guard = stdout
504        .lock()
505        .map_err(|e| anyhow::anyhow!("stdout mutex poisoned: {e}"))?;
506    writeln!(&mut *guard, "{json}")?;
507    guard.flush()?;
508    Ok(())
509}
510
511/// FALSIFY-MCP-PROGRESS-001: write one `notifications/progress` line to
512/// stdout under the same mutex used for final responses. Called from the
513/// worker-local `NotificationSink` built in
514/// [`AprMcpServer::spawn_tools_call_worker`].
515#[cfg(feature = "native")]
516fn write_notification(
517    stdout: &Arc<Mutex<std::io::Stdout>>,
518    notif: &JsonRpcNotification,
519) -> anyhow::Result<()> {
520    use std::io::Write;
521
522    let json = notif.to_json_line()?;
523    let mut guard = stdout
524        .lock()
525        .map_err(|e| anyhow::anyhow!("stdout mutex poisoned: {e}"))?;
526    writeln!(&mut *guard, "{json}")?;
527    guard.flush()?;
528    Ok(())
529}
530
531#[cfg(test)]
532#[allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
533mod tests {
534    use super::*;
535
536    fn make_request(method: &str, params: serde_json::Value) -> JsonRpcRequest {
537        JsonRpcRequest {
538            jsonrpc: "2.0".to_string(),
539            id: Some(serde_json::json!(1)),
540            method: method.to_string(),
541            params,
542        }
543    }
544
545    /// FALSIFY-MCP-001: initialize returns protocolVersion "2024-11-05".
546    #[test]
547    fn initialize_returns_protocol_version() {
548        let mut server = AprMcpServer::new();
549        let req = make_request("initialize", serde_json::json!({}));
550        let resp = server.handle_request(&req);
551
552        assert!(resp.error.is_none());
553        let result = resp.result.expect("result present");
554        assert_eq!(result["protocolVersion"], "2024-11-05");
555        assert_eq!(result["serverInfo"]["name"], "aprender-mcp");
556        assert!(result["capabilities"]["tools"].is_object());
557    }
558
559    /// FALSIFY-MCP-002: tools/list returns every registered tool. The
560    /// Phase-1 8-tool set (M2 subprocess wrappers + M3 `apr.finetune`) plus
561    /// the `apr.version` M1 scaffold is what a conforming dispatcher now
562    /// advertises; adding a new tool should fail this test until the contract
563    /// YAML and codegen are updated in lockstep.
564    #[test]
565    fn tools_list_returns_registered_tools() {
566        let mut server = AprMcpServer::new();
567        let req = make_request("tools/list", serde_json::json!({}));
568        let resp = server.handle_request(&req);
569
570        let result = resp.result.expect("result present");
571        let tools = result["tools"].as_array().expect("tools array");
572        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
573        for expected in [
574            "apr.version",
575            "apr.validate",
576            "apr.tensors",
577            "apr.bench",
578            "apr.qa",
579            "apr.trace",
580            "apr.run",
581            "apr.serve",
582            "apr.finetune",
583        ] {
584            assert!(names.contains(&expected), "{expected} registered");
585        }
586
587        for tool in tools {
588            assert_eq!(tool["inputSchema"]["type"], "object");
589        }
590    }
591
592    #[test]
593    fn tools_call_version_returns_metadata() {
594        let mut server = AprMcpServer::new();
595        let req = make_request(
596            "tools/call",
597            serde_json::json!({ "name": "apr.version", "arguments": {} }),
598        );
599        let resp = server.handle_request(&req);
600
601        let result = resp.result.expect("result present");
602        let text = result["content"][0]["text"].as_str().expect("text");
603        let parsed: serde_json::Value = serde_json::from_str(text).expect("json");
604        assert_eq!(parsed["server"], "aprender-mcp");
605        assert_eq!(parsed["protocol_version"], "2024-11-05");
606    }
607
608    #[test]
609    fn unknown_method_returns_method_not_found() {
610        let mut server = AprMcpServer::new();
611        let req = make_request("tools/explode", serde_json::json!({}));
612        let resp = server.handle_request(&req);
613
614        assert!(resp.result.is_none());
615        let err = resp.error.expect("error present");
616        assert_eq!(err.code, -32601);
617    }
618
619    /// `apr.validate` without `model_path` must return `isError: true` via
620    /// the argument-validation branch (no subprocess spawn).
621    #[test]
622    fn tools_call_validate_missing_model_path_is_error() {
623        let mut server = AprMcpServer::new();
624        let req = make_request(
625            "tools/call",
626            serde_json::json!({ "name": "apr.validate", "arguments": {} }),
627        );
628        let resp = server.handle_request(&req);
629
630        let result = resp.result.expect("result present");
631        assert_eq!(result["isError"], true);
632        let text = result["content"][0]["text"].as_str().expect("text");
633        assert!(text.contains("model_path"));
634    }
635
636    #[test]
637    fn tools_call_unknown_tool_returns_is_error() {
638        let mut server = AprMcpServer::new();
639        let req = make_request(
640            "tools/call",
641            serde_json::json!({ "name": "apr.nonexistent" }),
642        );
643        let resp = server.handle_request(&req);
644
645        let result = resp.result.expect("result present");
646        assert_eq!(result["isError"], true);
647    }
648
649    #[test]
650    fn tools_call_missing_name_returns_is_error() {
651        let mut server = AprMcpServer::new();
652        let req = make_request("tools/call", serde_json::json!({}));
653        let resp = server.handle_request(&req);
654
655        let result = resp.result.expect("result present");
656        assert_eq!(result["isError"], true);
657    }
658
659    #[test]
660    fn id_is_echoed_back() {
661        let mut server = AprMcpServer::new();
662        let req = JsonRpcRequest {
663            jsonrpc: "2.0".to_string(),
664            id: Some(serde_json::json!("req-42")),
665            method: "initialize".to_string(),
666            params: serde_json::json!({}),
667        };
668        let resp = server.handle_request(&req);
669        assert_eq!(resp.id, Some(serde_json::json!("req-42")));
670    }
671
672    /// FALSIFY-MCP-006 (unit): registering an id and then cancelling it
673    /// signals the receiver and removes the entry.
674    #[test]
675    fn cancel_in_flight_signals_and_deregisters() {
676        let server = AprMcpServer::new();
677        let id = serde_json::json!(99);
678        let rx = AprMcpServer::register_in_flight(&server.in_flight, id.clone());
679
680        let signalled = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
681        assert!(signalled, "live id should signal");
682        // Sender was dropped by cancel_in_flight (removed from the map), so
683        // try_recv must see either the signal or a disconnected channel —
684        // both prove the cancel reached the receiver side.
685        let received = rx.try_recv();
686        assert!(received.is_ok(), "cancel signal must be deliverable");
687
688        // Idempotent: second call is a no-op.
689        let signalled_again = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
690        assert!(
691            !signalled_again,
692            "cancelling an already-removed id is a no-op"
693        );
694    }
695
696    /// FALSIFY-MCP-006 (unit): cancelling an unknown id is a safe no-op.
697    #[test]
698    fn cancel_unknown_id_is_noop() {
699        let server = AprMcpServer::new();
700        let id = serde_json::json!("never-registered");
701        let signalled = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
702        assert!(!signalled);
703    }
704}