Skip to main content

aprender_mcp/
server.rs

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