netget/server/jsonrpc/mod.rs
1//! JSON-RPC 2.0 server implementation
2//!
3//! JSON-RPC runs over HTTP POST. The LLM controls all RPC method calls and responses.
4//! Supports single requests, batch requests, and notifications.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full};
14use hyper::body::Incoming;
15use hyper::server::conn::http1;
16use hyper::service::service_fn;
17use hyper::{Method, Request, Response, StatusCode};
18use hyper_util::rt::TokioIo;
19use serde_json::{json, Value};
20use tokio::sync::mpsc;
21use tracing::error;
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::actions::protocol_trait::ActionResult;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::connection::ConnectionId;
29use crate::server::jsonrpc::actions::{JsonRpcProtocol, JSONRPC_METHOD_CALL_EVENT};
30use crate::state::app_state::AppState;
31
32/// JSON-RPC 2.0 standard error codes
33const PARSE_ERROR: i32 = -32700;
34const INVALID_REQUEST: i32 = -32600;
35const INTERNAL_ERROR: i32 = -32603;
36
37/// Backend saturated — transient, so the caller should back off and retry.
38///
39/// JSON-RPC 2.0 reserves -32000..=-32099 for implementation-defined server errors. Reporting
40/// overload as -32603 tells the caller the server is broken when it is only busy, and the two
41/// have to stay distinguishable for a client's retry policy to be right. `xmlrpc` and the MCP
42/// server use the same code for the same reason.
43const SERVER_BUSY: i32 = -32000;
44
45/// Largest request body accepted, in bytes.
46///
47/// hyper imposes no limit of its own and the body was buffered whole, parsed into a
48/// `serde_json::Value` and then pretty-printed into the trace log — so one client could grow
49/// the process without bound.
50pub const MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024;
51
52/// Largest batch accepted.
53///
54/// **Every batch member is a separate model call**, run sequentially on one held-open
55/// connection, so batch length is a direct amplification factor on the LLM backend: at the
56/// body cap above, a `{"jsonrpc":"2.0","method":"a","id":1}` member is about forty bytes, which
57/// is a hundred thousand model calls from a single unauthenticated POST. The body cap alone
58/// does not bound the expensive resource. 128 is far above any real JSON-RPC batch; a caller
59/// that genuinely wants more should be behind a script or static handler, which costs no model
60/// call at all.
61const MAX_BATCH_LEN: usize = 128;
62
63/// How long a peer that has connected and produced nothing may hold a slot.
64///
65/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
66/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
67/// nothing. That state carries no protocol yet, which is why this number is the same across
68/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
69/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
70/// two deployed norms.
71///
72/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
73/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
74/// defaulted duration to `None` when no timer is present and applies no deadline at all.
75const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
76
77/// How long a connection with nothing outstanding may sit idle between requests.
78///
79/// nginx's `keepalive_timeout` default. The obvious argument for something much longer is that
80/// JSON-RPC's callers are applications rather than browsers, and a long-poll client
81/// (`eth_getFilterChanges`-style cycles, an LSP over HTTP) can go minutes without speaking — but
82/// that argument does not survive reading what this bound actually measures. A *held-open* long
83/// poll is a request in flight, not silence: the watchdog reads
84/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which reports a
85/// connection with work in flight — a model round-trip, or an event a `manual` rule parked for a
86/// human — as not idle at all. What is left is a connection with nothing outstanding, and
87/// reopening one of those costs a loopback handshake.
88const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75);
89
90/// Concurrent connections this server admits.
91///
92/// The shared default. Each admitted connection may buffer one body of up to
93/// [`MAX_REQUEST_BODY_BYTES`] (4 MiB), so the cap is what turns that per-connection bound into
94/// a total one: 256 x 4 MiB is the ~1 GiB ceiling every netget HTTP server here is held to.
95const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
96
97/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
98///
99/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
100/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
101/// error reaches the wire (see `crate::utils::wire_failure`).
102const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
103 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
104
105/// JSON-RPC 2.0 server that delegates to LLM
106pub struct JsonRpcServer;
107
108impl JsonRpcServer {
109 /// Spawn the JSON-RPC server with integrated LLM actions
110 pub async fn spawn_with_llm_actions(
111 listen_addr: SocketAddr,
112 llm_client: OllamaClient,
113 app_state: Arc<AppState>,
114 status_tx: mpsc::UnboundedSender<String>,
115 _send_first: bool,
116 server_id: crate::state::ServerId,
117 ) -> anyhow::Result<SocketAddr> {
118 let listener =
119 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
120 let local_addr = listener.local_addr()?;
121 Log::new(Some(&status_tx)).info(format!("JSON-RPC server listening on {}", local_addr));
122
123 let protocol = Arc::new(JsonRpcProtocol::new());
124
125 // Spawn server loop
126 let task_registrar = app_state.clone();
127 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
128 let accept_handle = tokio::spawn(async move {
129 loop {
130 match crate::server::accept_bounded::accept_bounded(
131 &listener,
132 &limiter,
133 CONNECTION_CAP_REFUSAL,
134 "JSON-RPC",
135 Some(&status_tx),
136 )
137 .await
138 {
139 Ok((stream, remote_addr, permit)) => {
140 let connection_id =
141 ConnectionId::new(app_state.get_next_unified_id().await);
142 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
143 Log::new(Some(&status_tx)).info(format!(
144 "JSON-RPC connection {} from {}",
145 connection_id, remote_addr
146 ));
147
148 // Add connection to ServerInstance
149 use crate::state::server::{
150 ConnectionState as ServerConnectionState, ConnectionStatus,
151 ProtocolConnectionInfo,
152 };
153 let now = crate::utils::clock::Instant::now();
154 let conn_state = ServerConnectionState {
155 id: connection_id,
156 remote_addr,
157 local_addr: local_addr_conn,
158 bytes_sent: 0,
159 bytes_received: 0,
160 packets_sent: 0,
161 packets_received: 0,
162 last_activity: now,
163 status: ConnectionStatus::Active,
164 status_changed_at: now,
165 protocol_info: ProtocolConnectionInfo::empty(),
166 };
167 app_state
168 .add_connection_to_server(server_id, conn_state)
169 .await;
170 let _ = status_tx.send("__UPDATE_UI__".to_string());
171
172 let llm_client_clone = llm_client.clone();
173 let app_state_clone = app_state.clone();
174 let status_tx_clone = status_tx.clone();
175 let protocol_clone = protocol.clone();
176
177 // Spawn a task to handle this connection
178 // Tracked, not detached: stop_server must abort this task too.
179 let task_owner = app_state.clone();
180 task_owner
181 .spawn_server_task(server_id, async move {
182 // Held for the life of the connection, so the cap counts live
183 // clients rather than accepts.
184 let _permit = permit;
185
186 // First-byte bound, before hyper sees the socket.
187 //
188 // hyper owns every read once `serve_connection` starts, and a
189 // deadline on those reads would be wrong here rather than merely
190 // awkward: hyper keeps polling the connection for more input
191 // while a request is being answered, so such a deadline would
192 // fire in the middle of a model round-trip. `peek` waits for data
193 // without consuming it, so the request line is still there for
194 // hyper afterwards, and it bounds exactly the case that needs
195 // bounding - a peer that has connected and sent nothing at all.
196 let spoke = matches!(
197 tokio::time::timeout(
198 FIRST_BYTE_READ_TIMEOUT,
199 stream.peek(&mut [0u8; 1]),
200 )
201 .await,
202 Ok(Ok(n)) if n > 0
203 );
204 if !spoke {
205 Log::new(Some(&status_tx_clone)).debug(format!(
206 "JSON-RPC peer {} sent nothing for {}s; closing before \
207 any request",
208 remote_addr,
209 FIRST_BYTE_READ_TIMEOUT.as_secs()
210 ));
211 } else {
212 let io = TokioIo::new(stream);
213
214 // Clone for service closure
215 let status_for_service = status_tx_clone.clone();
216 let app_state_for_service = app_state_clone.clone();
217
218 // Tracks whether this connection is answering anything. A
219 // request waiting on the model, or parked for a human by a
220 // `manual` rule, holds the count above zero, so the idle
221 // watchdog below cannot close the connection the answer
222 // belongs to however long it takes.
223 let activity = std::sync::Arc::new(
224 crate::server::accept_bounded::ConnectionActivity::new(),
225 );
226 let activity_for_service = std::sync::Arc::clone(&activity);
227
228 // Create a service that handles JSON-RPC requests with LLM
229 let service = service_fn(move |req: Request<Incoming>| {
230 let llm_clone = llm_client_clone.clone();
231 let state_clone = app_state_for_service.clone();
232 let status_clone = status_for_service.clone();
233 let protocol_clone = protocol_clone.clone();
234 let activity = std::sync::Arc::clone(&activity_for_service);
235 async move {
236 let _busy = activity.busy();
237 handle_jsonrpc_request(
238 req,
239 connection_id,
240 llm_clone,
241 state_clone,
242 status_clone,
243 protocol_clone,
244 server_id,
245 )
246 .await
247 }
248 });
249
250 // Serve HTTP/1 on this connection
251 let conn = http1::Builder::new().serve_connection(io, service);
252 tokio::pin!(conn);
253 tokio::select! {
254 result = &mut conn => {
255 if let Err(err) = result {
256 error!(
257 "Error serving JSON-RPC connection: {:?}",
258 err
259 );
260 }
261 }
262 _ = crate::server::accept_bounded::watch_idle(
263 std::sync::Arc::clone(&activity),
264 IDLE_BETWEEN_REQUESTS_TIMEOUT,
265 ) => {
266 Log::new(Some(&status_tx_clone)).debug(format!(
267 "JSON-RPC connection {} idle for {}s; closing",
268 connection_id,
269 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
270 ));
271 }
272 }
273 }
274
275 // Mark connection as closed
276 app_state_clone
277 .close_connection_on_server(server_id, connection_id)
278 .await;
279 Log::new(Some(&status_tx_clone))
280 .info(format!("JSON-RPC connection {} closed", connection_id));
281 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
282 })
283 .await;
284 }
285 Err(e) => {
286 Log::new(Some(&status_tx))
287 .error(format!("Failed to accept JSON-RPC connection: {}", e));
288 break;
289 }
290 }
291 }
292 });
293
294 task_registrar
295 .register_server_task(server_id, accept_handle)
296 .await;
297
298 Ok(local_addr)
299 }
300}
301
302/// Handle a single JSON-RPC request
303async fn handle_jsonrpc_request(
304 req: Request<Incoming>,
305 connection_id: ConnectionId,
306 llm_client: OllamaClient,
307 app_state: Arc<AppState>,
308 status_tx: mpsc::UnboundedSender<String>,
309 protocol: Arc<JsonRpcProtocol>,
310 server_id: crate::state::ServerId,
311) -> Result<Response<Full<Bytes>>, Infallible> {
312 let method = req.method().clone();
313 let uri = req.uri().clone();
314 let log = Log::new(Some(&status_tx));
315
316 log.debug(format!("JSON-RPC request: {} {}", method, uri.path()));
317
318 // JSON-RPC requires POST method
319 if method != Method::POST {
320 return Ok(build_error_response(
321 INVALID_REQUEST,
322 "JSON-RPC requires POST method",
323 None,
324 None,
325 ));
326 }
327
328 // Read request body, capped. hyper imposes no limit and the whole body is buffered,
329 // parsed and then pretty-printed into the trace log below.
330 let body_bytes = match http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES)
331 .collect()
332 .await
333 {
334 Ok(collected) => collected.to_bytes(),
335 Err(e) => {
336 // Non-fatal: the client gets an error response (wire fallback). The peer learns
337 // the limit, which is actionable; the codec's own message is not.
338 tracing::warn!("JSON-RPC request body rejected (decision=reject_oversized): {e}");
339 log.warn(format!("Failed to read request body: {}", e));
340 return Ok(build_error_response(
341 INVALID_REQUEST,
342 &format!(
343 "Request body rejected (limit {} bytes)",
344 MAX_REQUEST_BODY_BYTES
345 ),
346 None,
347 None,
348 ));
349 }
350 };
351
352 // Parse JSON
353 let request_value: Value = match serde_json::from_slice(&body_bytes) {
354 Ok(json) => json,
355 Err(e) => {
356 // Non-fatal: the client gets a Parse error response (wire fallback).
357 log.warn(format!("Failed to parse JSON: {}", e));
358 return Ok(build_error_response(PARSE_ERROR, "Parse error", None, None));
359 }
360 };
361
362 // Full payload FileOnly: the jsonrpc_method_call event template renders the
363 // method to the TUI.
364 log.trace(format!(
365 "JSON-RPC request body: {}",
366 serde_json::to_string_pretty(&request_value).unwrap_or_default()
367 ));
368
369 // Check if it's a batch request (array) or single request (object)
370 match request_value {
371 Value::Array(requests) if !requests.is_empty() => {
372 // Batch request
373 if requests.len() > MAX_BATCH_LEN {
374 tracing::warn!(
375 "JSON-RPC batch of {} rejected (decision=reject_oversized_batch, limit={})",
376 requests.len(),
377 MAX_BATCH_LEN
378 );
379 log.warn(format!(
380 "JSON-RPC batch of {} rejected (limit {})",
381 requests.len(),
382 MAX_BATCH_LEN
383 ));
384 return Ok(build_error_response(
385 INVALID_REQUEST,
386 &format!("Batch too large (limit {} requests)", MAX_BATCH_LEN),
387 None,
388 None,
389 ));
390 }
391
392 log.debug(format!(
393 "Processing batch JSON-RPC request with {} items",
394 requests.len()
395 ));
396
397 let mut responses = Vec::new();
398 for request in requests {
399 if let Some(response) = process_single_request(
400 request,
401 connection_id,
402 &llm_client,
403 &app_state,
404 &status_tx,
405 &protocol,
406 server_id,
407 )
408 .await
409 {
410 responses.push(response);
411 }
412 }
413
414 // Spec §6: "If there are no Response objects contained within the
415 // Response array as it is to be sent to the client, the server MUST NOT
416 // return an empty Array and should return nothing at all." A batch of
417 // nothing but notifications used to answer HTTP 200 with `[]`.
418 if responses.is_empty() {
419 return Ok(Response::builder()
420 .status(StatusCode::NO_CONTENT)
421 .body(Full::new(Bytes::new()))
422 .unwrap());
423 }
424
425 // Return batch response
426 let response_json = json!(responses);
427 Ok(Response::builder()
428 .status(StatusCode::OK)
429 .header("Content-Type", "application/json")
430 .body(Full::new(Bytes::from(response_json.to_string())))
431 .unwrap())
432 }
433 Value::Object(_) => {
434 // Single request
435 if let Some(response) = process_single_request(
436 request_value,
437 connection_id,
438 &llm_client,
439 &app_state,
440 &status_tx,
441 &protocol,
442 server_id,
443 )
444 .await
445 {
446 let response_json = response;
447 Ok(Response::builder()
448 .status(StatusCode::OK)
449 .header("Content-Type", "application/json")
450 .body(Full::new(Bytes::from(response_json.to_string())))
451 .unwrap())
452 } else {
453 // Notification (no response)
454 Ok(Response::builder()
455 .status(StatusCode::NO_CONTENT)
456 .body(Full::new(Bytes::new()))
457 .unwrap())
458 }
459 }
460 Value::Array(_) => {
461 // Empty batch request
462 Ok(build_error_response(
463 INVALID_REQUEST,
464 "Empty batch request",
465 None,
466 None,
467 ))
468 }
469 _ => {
470 // Invalid request type
471 Ok(build_error_response(
472 INVALID_REQUEST,
473 "Request must be an object or array",
474 None,
475 None,
476 ))
477 }
478 }
479}
480
481/// Process a single JSON-RPC request
482/// Returns None for notifications (no id field)
483async fn process_single_request(
484 request: Value,
485 connection_id: ConnectionId,
486 llm_client: &OllamaClient,
487 app_state: &Arc<AppState>,
488 status_tx: &mpsc::UnboundedSender<String>,
489 protocol: &Arc<JsonRpcProtocol>,
490 server_id: crate::state::ServerId,
491) -> Option<Value> {
492 let log = Log::new(Some(status_tx));
493 // A batch member that is not an object is not a request at all. Spec §6
494 // requires an Invalid Request response for each such member, with a null id;
495 // it used to be dropped silently, so `[1,2,3]` produced an empty array.
496 if !request.is_object() {
497 return Some(json!({
498 "jsonrpc": "2.0",
499 "error": {
500 "code": INVALID_REQUEST,
501 "message": "Request must be a JSON object"
502 },
503 "id": Value::Null
504 }));
505 }
506
507 // Extract fields
508 let jsonrpc_version = request.get("jsonrpc").and_then(|v| v.as_str());
509 let method = request.get("method").and_then(|v| v.as_str());
510 let params = request.get("params").cloned();
511 let id = request.get("id").cloned();
512
513 // Spec §4: "A Notification is a Request object without an 'id' member."
514 // An explicit `"id": null` is a Request (discouraged, but valid) and must be
515 // answered with `"id": null`. Only the absence of the member makes it a
516 // notification.
517 let is_notification = id.is_none();
518
519 // Validate JSON-RPC version
520 if jsonrpc_version != Some("2.0") {
521 if !is_notification {
522 return Some(json!({
523 "jsonrpc": "2.0",
524 "error": {
525 "code": INVALID_REQUEST,
526 "message": "Invalid JSON-RPC version, must be '2.0'"
527 },
528 "id": id
529 }));
530 } else {
531 return None; // Notifications never return errors
532 }
533 }
534
535 // Validate method
536 let method = match method {
537 Some(m) if !m.is_empty() => m,
538 _ => {
539 if !is_notification {
540 return Some(json!({
541 "jsonrpc": "2.0",
542 "error": {
543 "code": INVALID_REQUEST,
544 "message": "Missing or invalid 'method' field"
545 },
546 "id": id
547 }));
548 } else {
549 return None; // Notifications never return errors
550 }
551 }
552 };
553
554 log.debug(format!(
555 "JSON-RPC method call: method={}, is_notification={}",
556 method, is_notification
557 ));
558
559 // Call LLM with method details
560 let response_value = match call_llm_for_method(
561 method,
562 params.as_ref(),
563 id.clone(),
564 llm_client,
565 app_state,
566 status_tx,
567 protocol,
568 connection_id,
569 server_id,
570 )
571 .await
572 {
573 Ok(result) => result,
574 Err(e) => {
575 // Three outcomes an operator has to tell apart, and only a `decision=` tag can
576 // carry the difference here: the backend was saturated (transient), the backend
577 // erred (not), and — in `call_llm_for_method` below — the handler ran but answered
578 // with nothing usable. A notification is answered with silence whichever it was,
579 // so without these tags a swallowed failure left no trace at all.
580 let failure = crate::utils::WireFailure::classify(&e);
581 let (code, decision) = if failure.is_overloaded() {
582 (SERVER_BUSY, "fail_closed_llm_overloaded")
583 } else {
584 (INTERNAL_ERROR, "fail_closed_llm_error")
585 };
586 tracing::error!(
587 "JSON-RPC {} failed (decision={decision}, code={code}, notification={is_notification}): {e:#}",
588 method
589 );
590 log.warn(format!(
591 "JSON-RPC {} failed (decision={}): {}",
592 method, decision, e
593 ));
594 if !is_notification {
595 // Category only — never the error text. See `crate::utils::wire_failure`.
596 return Some(json!({
597 "jsonrpc": "2.0",
598 "error": {
599 "code": code,
600 "message": failure.text(),
601 "data": {"retryable": failure.is_overloaded()}
602 },
603 "id": id
604 }));
605 } else {
606 return None; // Notifications never return errors
607 }
608 }
609 };
610
611 // Return response (or None for notifications)
612 if !is_notification {
613 Some(response_value)
614 } else {
615 log.trace("Notification processed, no response sent");
616 None
617 }
618}
619
620/// Call LLM to handle the JSON-RPC method call
621async fn call_llm_for_method(
622 method: &str,
623 params: Option<&Value>,
624 request_id: Option<Value>,
625 llm_client: &OllamaClient,
626 app_state: &Arc<AppState>,
627 status_tx: &mpsc::UnboundedSender<String>,
628 protocol: &Arc<JsonRpcProtocol>,
629 connection_id: ConnectionId,
630 server_id: crate::state::ServerId,
631) -> anyhow::Result<Value> {
632 // Create JSON-RPC method call event.
633 //
634 // `is_notification` is explicit because `id` cannot carry the distinction: a
635 // missing id and an explicit `"id": null` both serialise to null here, yet the
636 // first must not be answered and the second must be. Without this field a
637 // script handler had no way to tell them apart.
638 let event_data = if let Some(params_val) = params {
639 json!({
640 "method": method,
641 "params": params_val,
642 "id": request_id,
643 "is_notification": request_id.is_none(),
644 })
645 } else {
646 json!({
647 "method": method,
648 "id": request_id,
649 "is_notification": request_id.is_none(),
650 })
651 };
652
653 let event = Event::new(&JSONRPC_METHOD_CALL_EVENT, event_data);
654 let log = Log::new(Some(status_tx));
655
656 log.debug(format!("Calling LLM for JSON-RPC method: {}", method));
657
658 // Call LLM with event
659 let llm_result = call_llm(
660 llm_client,
661 app_state,
662 server_id,
663 Some(connection_id),
664 &event,
665 protocol.as_ref(),
666 )
667 .await?;
668
669 log.trace(format!(
670 "LLM actions for JSON-RPC: {:?}",
671 llm_result.raw_actions.len()
672 ));
673
674 // Pick the response out of everything the handler produced.
675 //
676 // call_llm has already executed every action, so we read the results rather
677 // than re-executing (which used to render each log template twice and record
678 // the pre-id-fill action in the access log). Crucially we *scan* instead of
679 // taking raw_actions.first(): raw_actions includes common actions, so a
680 // perfectly reasonable response that leads with show_message or update_memory
681 // used to be rejected as a "non-JSON-RPC action" and turned into -32603. The
682 // protocol's own documentation and its notification test both used that shape.
683 let response = llm_result
684 .protocol_results
685 .iter()
686 .find_map(|result| collect_jsonrpc_response(result));
687
688 let Some(mut response) = response else {
689 // The handler ran without erroring and produced nothing usable. Distinct from a
690 // backend failure (logged as `fail_closed_*` by the caller) and from a handler that
691 // deliberately chose `jsonrpc_error` — which reaches the wire as the code it named.
692 // This used to be silent on both sinks, so an instruction the model could not follow
693 // looked identical to a broken backend.
694 tracing::warn!(
695 "JSON-RPC {} answered -32603 (decision=model_no_answer): handler produced neither \
696 jsonrpc_success nor jsonrpc_error",
697 method
698 );
699 log.warn(format!(
700 "JSON-RPC {} (decision=model_no_answer): no jsonrpc_success or jsonrpc_error",
701 method
702 ));
703 return Ok(json!({
704 "jsonrpc": "2.0",
705 "error": {
706 "code": INTERNAL_ERROR,
707 "message": "Handler did not produce a jsonrpc_success or jsonrpc_error action"
708 },
709 "id": request_id.unwrap_or(Value::Null)
710 }));
711 };
712
713 // The correlation id belongs to the request, not to the model. JSON-RPC 2.0
714 // §5 requires it to equal the request id, preserving type (a string id must
715 // come back as a string), so it is overwritten unconditionally: a handler that
716 // invents an id would otherwise produce a reply the client cannot match, and
717 // over keep-alive that failure is silent.
718 if let Some(obj) = response.as_object_mut() {
719 obj.insert("id".to_string(), request_id.clone().unwrap_or(Value::Null));
720 obj.insert("jsonrpc".to_string(), Value::String("2.0".to_string()));
721 }
722
723 Ok(response)
724}
725
726/// Extract a JSON-RPC response object from one action result, if it is one.
727/// Handles `Multiple` so a nested result is not silently dropped.
728fn collect_jsonrpc_response(result: &ActionResult) -> Option<Value> {
729 match result {
730 ActionResult::Custom { name, data } if name == "jsonrpc_response" => Some(data.clone()),
731 ActionResult::Multiple(inner) => inner.iter().find_map(collect_jsonrpc_response),
732 _ => None,
733 }
734}
735
736/// Build a JSON-RPC error response
737//
738// `track_method_call` used to sit here, maintaining a ten-entry `recent_methods` ring inside
739// the connection's `protocol_info`. Nothing in the tree read it — not the dashboard, which
740// reads `protocol_info` only through IMAP-specific accessors, not the MCP surface, not a test
741// — and it cost a write lock on the one global `AppState` `RwLock` on every single request,
742// plus a clone-and-reparse of the whole vector. It is deleted rather than left as a
743// throughput cost with no reader; the method name is already in the access log and in the
744// event's own log template.
745fn build_error_response(
746 code: i32,
747 message: &str,
748 data: Option<Value>,
749 id: Option<Value>,
750) -> Response<Full<Bytes>> {
751 let mut error = json!({
752 "code": code,
753 "message": message
754 });
755
756 if let Some(data_val) = data {
757 error
758 .as_object_mut()
759 .unwrap()
760 .insert("data".to_string(), data_val);
761 }
762
763 let response = json!({
764 "jsonrpc": "2.0",
765 "error": error,
766 "id": id.unwrap_or(Value::Null)
767 });
768
769 Response::builder()
770 .status(StatusCode::OK) // JSON-RPC always returns 200 OK
771 .header("Content-Type", "application/json")
772 .body(Full::new(Bytes::from(response.to_string())))
773 .unwrap()
774}