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