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                // FALSIFY-MCP-009: JSON-RPC 2.0 §4.1 — a Request object
346                // without an `id` member is a *Notification*, and "The Server
347                // MUST NOT reply to a Notification." The `notifications/*`
348                // method prefix is an MCP convention, but conformance is
349                // determined by the *absence of an id*, not the method name. A
350                // client that sends e.g. `{"jsonrpc":"2.0","method":"initialize"}`
351                // (no id) or an unknown method with no id is issuing a
352                // notification; emitting a response with `id:null` would
353                // corrupt the stream for a strict peer. Drop it silently.
354                if req.id.is_none() {
355                    return Ok(());
356                }
357                let resp = self.handle_request(&req);
358                write_response(stdout, &resp)
359            }
360        }
361    }
362
363    #[cfg(feature = "native")]
364    fn spawn_tools_call_worker(
365        &mut self,
366        req: JsonRpcRequest,
367        stdout: &Arc<Mutex<std::io::Stdout>>,
368    ) -> anyhow::Result<()> {
369        // Notifications would arrive with id = None; tools/call must have
370        // an id per JSON-RPC. Defensive: if it's missing, respond inline
371        // with an error so the client sees the failure immediately.
372        let Some(id) = req.id.clone() else {
373            let resp =
374                JsonRpcResponse::error(None, -32600, "Invalid Request: tools/call requires an id");
375            return write_response(stdout, &resp);
376        };
377
378        let cancel_rx = Self::register_in_flight(&self.in_flight, id.clone());
379        let stdout_clone = Arc::clone(stdout);
380        let in_flight_clone = Arc::clone(&self.in_flight);
381        let params = req.params.clone();
382        let id_for_worker = id.clone();
383        let progress_token = extract_progress_token(&params);
384
385        // Build a stdout-backed notification sink for this worker. The sink
386        // shares the response stdout mutex so progress lines and the final
387        // response can never interleave. Per MCP spec the sink is only
388        // wired when the client advertised a progressToken.
389        let sink_stdout = Arc::clone(stdout);
390        let sink: NotificationSink = Box::new(move |notif| {
391            // Best-effort: a broken stdout means the client disconnected.
392            let _ = write_notification(&sink_stdout, &notif);
393        });
394
395        // Thread spawn is infallible here in practice, but propagate the
396        // error rather than unwrapping so we stay in the "no panics" lane.
397        let builder = std::thread::Builder::new().name(format!("apr-mcp-call-{id}"));
398        let spawn_result = builder.spawn(move || {
399            let sink_ref = progress_token.as_ref().map(|_| &sink);
400            let result =
401                dispatch_tool_call_with_sink(&params, &cancel_rx, sink_ref, progress_token);
402            let resp = JsonRpcResponse::success(
403                Some(id_for_worker.clone()),
404                serde_json::to_value(result).unwrap_or_else(|_| serde_json::json!({})),
405            );
406            // Best-effort: a broken stdout means the client disconnected,
407            // which we can't recover from anyway.
408            let _ = write_response(&stdout_clone, &resp);
409            Self::deregister_in_flight(&in_flight_clone, &id_for_worker);
410        });
411
412        match spawn_result {
413            Ok(_handle) => Ok(()),
414            Err(e) => {
415                // Failed to spawn — clean up the registry entry we just
416                // inserted and report the failure inline.
417                Self::deregister_in_flight(&self.in_flight, &id);
418                let resp = JsonRpcResponse::error(
419                    Some(id),
420                    -32603,
421                    format!("Internal error: failed to spawn worker thread: {e}"),
422                );
423                write_response(stdout, &resp)
424            }
425        }
426    }
427
428    /// Handle for tests that want to inspect the in-flight registry.
429    #[must_use]
430    pub fn in_flight_handle(&self) -> InFlight {
431        Arc::clone(&self.in_flight)
432    }
433}
434
435/// Shared tool-call dispatch logic used by both the sync and stdio paths.
436///
437/// `cancel_rx` is forwarded to `apr.run` only; the other tools ignore it.
438/// Callers that never need progress streaming can keep using this wrapper;
439/// the [`dispatch_tool_call_with_sink`] variant exposes the
440/// FALSIFY-MCP-PROGRESS-001 path.
441fn dispatch_tool_call(
442    params: &serde_json::Value,
443    cancel_rx: &mpsc::Receiver<()>,
444    sink: Option<&NotificationSink>,
445) -> ToolCallResult {
446    dispatch_tool_call_with_sink(params, cancel_rx, sink, None)
447}
448
449/// Full dispatch variant with optional `NotificationSink` + `progressToken`.
450///
451/// FALSIFY-MCP-PROGRESS-001 / FALSIFY-MCP-PROGRESS-002: when `sink` and
452/// `progress_token` are both `Some`, tools that support streaming
453/// (`apr.finetune` and `apr.run`) forward each stdout line as a
454/// `notifications/progress` message via `sink` before returning the final
455/// `ToolCallResult`. Tools that don't support streaming ignore the sink and
456/// run synchronously.
457fn dispatch_tool_call_with_sink(
458    params: &serde_json::Value,
459    cancel_rx: &mpsc::Receiver<()>,
460    sink: Option<&NotificationSink>,
461    progress_token: Option<serde_json::Value>,
462) -> ToolCallResult {
463    let name = params.get("name").and_then(|v| v.as_str());
464    let arguments = params
465        .get("arguments")
466        .cloned()
467        .unwrap_or_else(|| serde_json::json!({}));
468
469    // HELIX-IDEA-002 / FALSIFY-INVENTORY-003: dispatch goes through the
470    // inventory-built name → fn-pointer index. Every shipped tool's
471    // module owns a `dispatch` shim that adapts to the unified
472    // `DispatchFn` signature (FALSIFY-MCP-PROGRESS-002 still applies for
473    // `apr.run` and `apr.finetune`; sink + progress_token forward through
474    // the shim as before).
475    let Some(name) = name else {
476        return ToolCallResult::error("Missing tool name");
477    };
478    match tool_index().dispatch_for(name) {
479        Some(dispatch_fn) => dispatch_fn(&arguments, cancel_rx, sink, progress_token),
480        None => ToolCallResult::error(format!("Unknown tool: {name}")),
481    }
482}
483
484/// Module-local inventory cache. Built once on first access via
485/// [`crate::tools::ToolIndex::from_inventory`]; that call panics
486/// (FALSIFY-INVENTORY-002) if two tools advertise the same name, so a
487/// duplicate-registration regression fails every test that hits the
488/// dispatcher rather than silently shadowing one entry.
489fn tool_index() -> &'static crate::tools::ToolIndex {
490    static INDEX: std::sync::OnceLock<crate::tools::ToolIndex> = std::sync::OnceLock::new();
491    INDEX.get_or_init(crate::tools::ToolIndex::from_inventory)
492}
493
494/// Pull `params._meta.progressToken` out of a `tools/call` request. Returns
495/// `None` when the field is absent — per MCP 2024-11-05 the server MUST NOT
496/// emit progress notifications in that case.
497fn extract_progress_token(params: &serde_json::Value) -> Option<serde_json::Value> {
498    params
499        .get("_meta")
500        .and_then(|m| m.get("progressToken"))
501        .cloned()
502}
503
504#[cfg(feature = "native")]
505fn write_response(
506    stdout: &Arc<Mutex<std::io::Stdout>>,
507    resp: &JsonRpcResponse,
508) -> anyhow::Result<()> {
509    use std::io::Write;
510
511    let json = serde_json::to_string(resp)?;
512    let mut guard = stdout
513        .lock()
514        .map_err(|e| anyhow::anyhow!("stdout mutex poisoned: {e}"))?;
515    writeln!(&mut *guard, "{json}")?;
516    guard.flush()?;
517    Ok(())
518}
519
520/// FALSIFY-MCP-PROGRESS-001: write one `notifications/progress` line to
521/// stdout under the same mutex used for final responses. Called from the
522/// worker-local `NotificationSink` built in
523/// [`AprMcpServer::spawn_tools_call_worker`].
524#[cfg(feature = "native")]
525fn write_notification(
526    stdout: &Arc<Mutex<std::io::Stdout>>,
527    notif: &JsonRpcNotification,
528) -> anyhow::Result<()> {
529    use std::io::Write;
530
531    let json = notif.to_json_line()?;
532    let mut guard = stdout
533        .lock()
534        .map_err(|e| anyhow::anyhow!("stdout mutex poisoned: {e}"))?;
535    writeln!(&mut *guard, "{json}")?;
536    guard.flush()?;
537    Ok(())
538}
539
540#[cfg(test)]
541#[allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
542mod tests {
543    use super::*;
544
545    fn make_request(method: &str, params: serde_json::Value) -> JsonRpcRequest {
546        JsonRpcRequest {
547            jsonrpc: "2.0".to_string(),
548            id: Some(serde_json::json!(1)),
549            method: method.to_string(),
550            params,
551        }
552    }
553
554    /// FALSIFY-MCP-001: initialize returns protocolVersion "2024-11-05".
555    #[test]
556    fn initialize_returns_protocol_version() {
557        let mut server = AprMcpServer::new();
558        let req = make_request("initialize", serde_json::json!({}));
559        let resp = server.handle_request(&req);
560
561        assert!(resp.error.is_none());
562        let result = resp.result.expect("result present");
563        assert_eq!(result["protocolVersion"], "2024-11-05");
564        assert_eq!(result["serverInfo"]["name"], "aprender-mcp");
565        assert!(result["capabilities"]["tools"].is_object());
566    }
567
568    /// FALSIFY-MCP-002: tools/list returns every registered tool. The
569    /// Phase-1 8-tool set (M2 subprocess wrappers + M3 `apr.finetune`) plus
570    /// the `apr.version` M1 scaffold is what a conforming dispatcher now
571    /// advertises; adding a new tool should fail this test until the contract
572    /// YAML and codegen are updated in lockstep.
573    #[test]
574    fn tools_list_returns_registered_tools() {
575        let mut server = AprMcpServer::new();
576        let req = make_request("tools/list", serde_json::json!({}));
577        let resp = server.handle_request(&req);
578
579        let result = resp.result.expect("result present");
580        let tools = result["tools"].as_array().expect("tools array");
581        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
582        for expected in [
583            "apr.version",
584            "apr.validate",
585            "apr.tensors",
586            "apr.bench",
587            "apr.qa",
588            "apr.trace",
589            "apr.run",
590            "apr.serve",
591            "apr.finetune",
592        ] {
593            assert!(names.contains(&expected), "{expected} registered");
594        }
595
596        for tool in tools {
597            assert_eq!(tool["inputSchema"]["type"], "object");
598        }
599    }
600
601    #[test]
602    fn tools_call_version_returns_metadata() {
603        let mut server = AprMcpServer::new();
604        let req = make_request(
605            "tools/call",
606            serde_json::json!({ "name": "apr.version", "arguments": {} }),
607        );
608        let resp = server.handle_request(&req);
609
610        let result = resp.result.expect("result present");
611        let text = result["content"][0]["text"].as_str().expect("text");
612        let parsed: serde_json::Value = serde_json::from_str(text).expect("json");
613        assert_eq!(parsed["server"], "aprender-mcp");
614        assert_eq!(parsed["protocol_version"], "2024-11-05");
615    }
616
617    #[test]
618    fn unknown_method_returns_method_not_found() {
619        let mut server = AprMcpServer::new();
620        let req = make_request("tools/explode", serde_json::json!({}));
621        let resp = server.handle_request(&req);
622
623        assert!(resp.result.is_none());
624        let err = resp.error.expect("error present");
625        assert_eq!(err.code, -32601);
626    }
627
628    /// `apr.validate` without `model_path` must return `isError: true` via
629    /// the argument-validation branch (no subprocess spawn).
630    #[test]
631    fn tools_call_validate_missing_model_path_is_error() {
632        let mut server = AprMcpServer::new();
633        let req = make_request(
634            "tools/call",
635            serde_json::json!({ "name": "apr.validate", "arguments": {} }),
636        );
637        let resp = server.handle_request(&req);
638
639        let result = resp.result.expect("result present");
640        assert_eq!(result["isError"], true);
641        let text = result["content"][0]["text"].as_str().expect("text");
642        assert!(text.contains("model_path"));
643    }
644
645    #[test]
646    fn tools_call_unknown_tool_returns_is_error() {
647        let mut server = AprMcpServer::new();
648        let req = make_request(
649            "tools/call",
650            serde_json::json!({ "name": "apr.nonexistent" }),
651        );
652        let resp = server.handle_request(&req);
653
654        let result = resp.result.expect("result present");
655        assert_eq!(result["isError"], true);
656    }
657
658    #[test]
659    fn tools_call_missing_name_returns_is_error() {
660        let mut server = AprMcpServer::new();
661        let req = make_request("tools/call", serde_json::json!({}));
662        let resp = server.handle_request(&req);
663
664        let result = resp.result.expect("result present");
665        assert_eq!(result["isError"], true);
666    }
667
668    #[test]
669    fn id_is_echoed_back() {
670        let mut server = AprMcpServer::new();
671        let req = JsonRpcRequest {
672            jsonrpc: "2.0".to_string(),
673            id: Some(serde_json::json!("req-42")),
674            method: "initialize".to_string(),
675            params: serde_json::json!({}),
676        };
677        let resp = server.handle_request(&req);
678        assert_eq!(resp.id, Some(serde_json::json!("req-42")));
679    }
680
681    /// FALSIFY-MCP-006 (unit): registering an id and then cancelling it
682    /// signals the receiver and removes the entry.
683    #[test]
684    fn cancel_in_flight_signals_and_deregisters() {
685        let server = AprMcpServer::new();
686        let id = serde_json::json!(99);
687        let rx = AprMcpServer::register_in_flight(&server.in_flight, id.clone());
688
689        let signalled = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
690        assert!(signalled, "live id should signal");
691        // Sender was dropped by cancel_in_flight (removed from the map), so
692        // try_recv must see either the signal or a disconnected channel —
693        // both prove the cancel reached the receiver side.
694        let received = rx.try_recv();
695        assert!(received.is_ok(), "cancel signal must be deliverable");
696
697        // Idempotent: second call is a no-op.
698        let signalled_again = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
699        assert!(
700            !signalled_again,
701            "cancelling an already-removed id is a no-op"
702        );
703    }
704
705    /// FALSIFY-MCP-006 (unit): cancelling an unknown id is a safe no-op.
706    #[test]
707    fn cancel_unknown_id_is_noop() {
708        let server = AprMcpServer::new();
709        let id = serde_json::json!("never-registered");
710        let signalled = AprMcpServer::cancel_in_flight(&server.in_flight, &id);
711        assert!(!signalled);
712    }
713}