Skip to main content

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}