Skip to main content

jev_repl/
mcp.rs

1//! jev as an MCP server: the one-shot commands, offered to an agent as tools.
2//!
3//! This half is pure — a JSON-RPC message in, a JSON-RPC message out — so the protocol can be
4//! tested without a pipe. Everything that touches the network goes through [`Host::ask`], which
5//! the caller supplies; [`crate::serve`] is the part that puts it on stdin and stdout.
6
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use serde_json::{Map, Value, json};
12
13use crate::cost::Rates;
14use crate::evaluate::{self, Outcome, ReportOptions};
15use crate::headless;
16use crate::session::Session;
17use crate::skill::SKILL_MD;
18use crate::{cost, presets, sketch};
19
20/// The protocol revision this server speaks.
21pub const PROTOCOL_VERSION: &str = "2025-06-18";
22
23/// Revisions a client may ask for and still be understood; anything else gets ours back.
24pub const KNOWN_PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
25
26/// The name the server reports at `initialize`, and the prefix on every tool.
27pub const SERVER_NAME: &str = "jev";
28
29/// JSON-RPC error codes, the ones this server can actually raise.
30pub const PARSE_ERROR: i64 = -32700;
31pub const INVALID_REQUEST: i64 = -32600;
32pub const METHOD_NOT_FOUND: i64 = -32601;
33pub const INVALID_PARAMS: i64 = -32602;
34
35/// What the host got back from one send: the answers `jev eval` scores, plus the raw body when
36/// there was one, so `jev_ask` can hand it over for a script to read.
37#[derive(Debug, Clone)]
38pub struct Sent {
39    pub outcome: Outcome,
40    pub raw: Option<Value>,
41}
42
43impl Sent {
44    pub fn failed(error: impl Into<String>) -> Self {
45        Self {
46            outcome: Outcome::Failed {
47                error: error.into(),
48            },
49            raw: None,
50        }
51    }
52}
53
54/// Send one session and come back with its answers, or with why it did not.
55///
56/// Boxed rather than generic: the server hands this to spawned tasks and to the eval runner, and
57/// one `Arc` is easier to pass around than a type parameter threaded through every function.
58pub type Ask =
59    Arc<dyn Fn(Session) -> Pin<Box<dyn Future<Output = Sent> + Send>> + Send + Sync + 'static>;
60
61/// What the host does for the tools that need more than text: send a request, price it, name a model.
62#[derive(Clone)]
63pub struct Host {
64    /// The version reported at `initialize`.
65    pub version: String,
66    /// The model a page that pins none is sent with.
67    pub model: String,
68    /// Whether answers come from the API. False means every answer is simulated.
69    pub live: bool,
70    /// Token prices, when the host was given any.
71    pub rates: Option<Rates>,
72    pub ask: Ask,
73}
74
75/// What a tool call comes back with: text, and whether it describes a failure.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ToolResult {
78    pub text: String,
79    pub is_error: bool,
80}
81
82impl ToolResult {
83    fn ok(text: impl Into<String>) -> Self {
84        Self {
85            text: text.into(),
86            is_error: false,
87        }
88    }
89
90    fn failed(text: impl Into<String>) -> Self {
91        Self {
92            text: text.into(),
93            is_error: true,
94        }
95    }
96
97    pub fn to_value(&self) -> Value {
98        let mut value = json!({ "content": [{ "type": "text", "text": self.text }] });
99        if self.is_error {
100            value["isError"] = Value::Bool(true);
101        }
102        value
103    }
104}
105
106const PAGE_DESCRIPTION: &str = "The request as a jev sketch page, or a raw /v1/systemone request body. \
107     jev_notation has the notation.";
108
109/// Every tool this server offers, in the order an agent should reach for them.
110pub fn tools() -> Value {
111    let page = json!({ "type": "string", "description": PAGE_DESCRIPTION });
112    let state = json!({
113        "type": "string",
114        "description": "Judge this text instead of the state written on the page.",
115    });
116    let model = json!({
117        "type": "string",
118        "description":
119            "The model to ask. Defaults to the one the page pins, then to the server's default.",
120    });
121    let threshold = json!({
122        "type": "number",
123        "description": "What counts as a yes for a noul, from 0 to 1. Defaults to 0.5.",
124    });
125    let price = json!({
126        "type": "string",
127        "description": "Dollars per million tokens, input then output, as \"0.20/1.00\".",
128    });
129    json!([
130        {
131            "name": "jev_notation",
132            "title": "jev notation",
133            "description":
134                "The jev sketch notation and workflow: how to write a page of questions, what each \
135                 kind of question answers with, and how to check, price, run and score one. Read \
136                 this before writing a page, and again when one will not parse.",
137            "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
138        },
139        {
140            "name": "jev_check",
141            "title": "Check a page",
142            "description":
143                "Parse a page and report every problem with a line number, or say what it parsed \
144                 into. Sends nothing and costs nothing — use it on every page before running one.",
145            "inputSchema": {
146                "type": "object",
147                "properties": { "page": page },
148                "required": ["page"],
149                "additionalProperties": false,
150            },
151        },
152        {
153            "name": "jev_request",
154            "title": "Request body",
155            "description":
156                "The exact JSON body this page would POST to /v1/systemone. Sends nothing.",
157            "inputSchema": {
158                "type": "object",
159                "properties": { "page": page, "state": state, "model": model },
160                "required": ["page"],
161                "additionalProperties": false,
162            },
163        },
164        {
165            "name": "jev_cost",
166            "title": "Estimate cost",
167            "description":
168                "Estimated tokens for this page, per question and on both sides of the wire, \
169                 priced when rates are given. Sends nothing. Check this before a run over many \
170                 cases.",
171            "inputSchema": {
172                "type": "object",
173                "properties": { "page": page, "state": state, "model": model, "price": price },
174                "required": ["page"],
175                "additionalProperties": false,
176            },
177        },
178        {
179            "name": "jev_ask",
180            "title": "Ask the questions",
181            "description":
182                "Send the page and return one answer per question. Spends money when the server \
183                 holds an API key; without one every answer is simulated noise and must not be \
184                 reported as judgement.",
185            "inputSchema": {
186                "type": "object",
187                "properties": {
188                    "page": page,
189                    "state": state,
190                    "model": model,
191                    "threshold": threshold,
192                    "json": {
193                        "type": "boolean",
194                        "description": "Return the raw response body instead of the answer page.",
195                    },
196                },
197                "required": ["page"],
198                "additionalProperties": false,
199            },
200        },
201        {
202            "name": "jev_eval",
203            "title": "Score a page",
204            "description":
205                "Run a page over labelled cases and score the answers: accuracy per question, a \
206                 confusion table, a threshold sweep for each noul. One request per case, so price \
207                 it first.",
208            "inputSchema": {
209                "type": "object",
210                "properties": {
211                    "page": page,
212                    "cases": {
213                        "type": "string",
214                        "description":
215                            "JSON Lines, one labelled state per line: \
216                             {\"state\": \"...\", \"expect\": {\"is_urgent\": true}}.",
217                    },
218                    "model": model,
219                    "threshold": threshold,
220                    "price": price,
221                    "concurrency": {
222                        "type": "integer",
223                        "description": "How many cases are in the air at once. Defaults to 4.",
224                        "minimum": 1,
225                    },
226                    "compare": {
227                        "type": "string",
228                        "description":
229                            "A second page to run over the same cases. The report becomes the \
230                             difference: deltas per question, the cases whose answer flipped, and \
231                             an exact McNemar test.",
232                    },
233                    "calibrate": {
234                        "type": "boolean",
235                        "description":
236                            "Also return the page with the bars this run supports written in: \
237                             each noul's best-F1 @threshold, and the lowest @confidence at which a \
238                             choice or score reaches targetAccuracy. Nothing else on the page \
239                             changes.",
240                    },
241                    "targetAccuracy": {
242                        "type": "number",
243                        "description": "The accuracy a confidence bar has to reach. Defaults to 0.9.",
244                        "minimum": 0,
245                        "maximum": 1,
246                    },
247                    "json": {
248                        "type": "boolean",
249                        "description": "Return the report as JSON instead of a table.",
250                    },
251                },
252                "required": ["page", "cases"],
253                "additionalProperties": false,
254            },
255        },
256        {
257            "name": "jev_code",
258            "title": "Page as code",
259            "description":
260                "The page as a working Rust program against typesafe-ai-sdk. Start here instead of \
261                 writing a client by hand.",
262            "inputSchema": {
263                "type": "object",
264                "properties": { "page": page, "model": model, "threshold": threshold },
265                "required": ["page"],
266                "additionalProperties": false,
267            },
268        },
269        {
270            "name": "jev_presets",
271            "title": "Ready-made pages",
272            "description":
273                "Worked pages to start from — support triage, content moderation, lead \
274                 qualification and reply grading — each as a sketch page ready to edit.",
275            "inputSchema": {
276                "type": "object",
277                "properties": {
278                    "name": {
279                        "type": "string",
280                        "description": "One preset by name. Omit for all of them.",
281                    },
282                },
283                "additionalProperties": false,
284            },
285        },
286    ])
287}
288
289/// The line every simulated answer is stamped with, so nobody reports noise as judgement.
290const SIMULATED: &str = "\nSimulated answers: deterministic noise, not judgement. \
291                         The server has no TYPESAFE_API_KEY, so nothing was sent.";
292
293fn string_arg(args: &Value, name: &str) -> Result<String, String> {
294    match args.get(name) {
295        None | Some(Value::Null) => Err(format!("{name} is required.")),
296        Some(Value::String(text)) => Ok(text.clone()),
297        Some(_) => Err(format!("{name} must be a string.")),
298    }
299}
300
301fn optional_string(args: &Value, name: &str) -> Result<Option<String>, String> {
302    match args.get(name) {
303        None | Some(Value::Null) => Ok(None),
304        Some(Value::String(text)) => Ok(Some(text.clone())),
305        Some(_) => Err(format!("{name} must be a string.")),
306    }
307}
308
309fn threshold_arg(args: &Value) -> Result<f64, String> {
310    let value = match args.get("threshold") {
311        None | Some(Value::Null) => return Ok(0.5),
312        Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
313        Some(_) => return Err("threshold must be a number.".to_owned()),
314    };
315    if !(0.0..=1.0).contains(&value) {
316        return Err("threshold must be from 0 to 1.".to_owned());
317    }
318    Ok(value)
319}
320
321fn bool_arg(args: &Value, name: &str) -> Result<bool, String> {
322    match args.get(name) {
323        None | Some(Value::Null) => Ok(false),
324        Some(Value::Bool(value)) => Ok(*value),
325        Some(_) => Err(format!("{name} must be true or false.")),
326    }
327}
328
329fn concurrency_arg(args: &Value) -> Result<usize, String> {
330    let workers = match args.get("concurrency") {
331        None | Some(Value::Null) => return Ok(4),
332        Some(Value::Number(n)) => n.as_i64().unwrap_or(0),
333        Some(_) => return Err("concurrency must be a number.".to_owned()),
334    };
335    if workers < 1 {
336        return Err("concurrency must be a whole number of 1 or more.".to_owned());
337    }
338    Ok(workers as usize)
339}
340
341/// The rates a call was given, falling back to the host's.
342fn rates_arg(args: &Value, host: &Host) -> Result<Option<Rates>, String> {
343    match optional_string(args, "price")? {
344        None => Ok(host.rates),
345        Some(text) => cost::parse_rates(&text).map(Some),
346    }
347}
348
349/// The page, loaded, with the overrides a call may carry applied.
350fn session_arg(args: &Value, host: &Host) -> Result<(Session, String), String> {
351    let mut session = headless::load(&string_arg(args, "page")?)?;
352    if let Some(state) = optional_string(args, "state")? {
353        session.state = Value::String(state);
354    }
355    if let Some(model) = optional_string(args, "model")? {
356        session.model = Some(model);
357    }
358    let model = session.model.clone().unwrap_or_else(|| host.model.clone());
359    Ok((session, model))
360}
361
362async fn ask(args: &Value, host: &Host) -> Result<ToolResult, String> {
363    let (session, model) = session_arg(args, host)?;
364    if let Some(why) = headless::sendable(&session) {
365        return Err(why);
366    }
367    let threshold = threshold_arg(args)?;
368    let rates = rates_arg(args, host)?;
369    let json_wanted = bool_arg(args, "json")?;
370
371    let sent = (host.ask)(session.clone()).await;
372    let (answers, usage) = match sent.outcome {
373        Outcome::Failed { error } => return Ok(ToolResult::failed(error)),
374        Outcome::Ok { answers, usage } => (answers, usage),
375    };
376    if json_wanted {
377        return Ok(ToolResult::ok(headless::answers_json(
378            &answers,
379            &model,
380            sent.raw.as_ref(),
381        )));
382    }
383    let mut text = headless::session_answers_text(&answers, threshold, &session);
384    text.push_str(&headless::usage_text(
385        &session,
386        &model,
387        rates,
388        usage.as_ref(),
389    ));
390    if !host.live {
391        text.push_str(SIMULATED);
392    }
393    Ok(ToolResult::ok(text))
394}
395
396async fn score(args: &Value, host: &Host) -> Result<ToolResult, String> {
397    let (session, model) = session_arg(args, host)?;
398    let threshold = threshold_arg(args)?;
399    let concurrency = concurrency_arg(args)?;
400    let rates = rates_arg(args, host)?;
401    if let Some(second) = optional_string(args, "compare")? {
402        return compared(
403            args,
404            host,
405            &second,
406            (session, model),
407            threshold,
408            concurrency,
409            rates,
410        )
411        .await;
412    }
413    let cases = evaluate::parse_cases(&string_arg(args, "cases")?, &session)?;
414    if cases.is_empty() {
415        return Err("cases is empty: nothing to score.".to_owned());
416    }
417    let calibrating = bool_arg(args, "calibrate")?;
418    let target = match args.get("targetAccuracy") {
419        None | Some(Value::Null) => evaluate::DEFAULT_TARGET,
420        Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
421        Some(_) => return Err("targetAccuracy must be a number.".to_owned()),
422    };
423    if !(0.0..=1.0).contains(&target) {
424        return Err("targetAccuracy must be from 0 to 1.".to_owned());
425    }
426    let page = string_arg(args, "page")?;
427    if calibrating && page.trim_start().starts_with('{') {
428        return Err(
429            "calibrate needs a .jev page: a request body has nowhere to keep a bar.".to_owned(),
430        );
431    }
432    let json_wanted = bool_arg(args, "json")?;
433
434    let outcomes = evaluate::run(&session, &cases, asker(host), concurrency).await;
435    let report = evaluate::report(
436        &session,
437        &cases,
438        &outcomes,
439        ReportOptions {
440            model: &model,
441            threshold,
442            rates,
443        },
444    );
445    let calibration = (calibrating && report.errors.is_empty())
446        .then(|| evaluate::calibrate(&session, &cases, &outcomes, &report, target));
447    let calibrated = calibration
448        .as_ref()
449        .map(|c| sketch::set_bars(&page, &c.changed));
450    let refused = calibrating && calibration.is_none();
451    let why = evaluate::not_calibrating(report.errors.len());
452
453    if json_wanted {
454        let mut json = evaluate::report_json(&report);
455        if let Value::Object(object) = &mut json {
456            if let (Some(calibration), Some(text)) = (&calibration, &calibrated) {
457                let mut value = evaluate::calibration_json(calibration, "page");
458                value.insert("text".to_owned(), Value::String(text.clone()));
459                object.insert("calibration".to_owned(), Value::Object(value));
460            }
461            if refused {
462                object.insert("calibration".to_owned(), json!({ "refused": why }));
463            }
464        }
465        let body = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
466        return Ok(ToolResult::ok(format!("{body}\n")));
467    }
468    let mut text = evaluate::report_text(&report);
469    if let (Some(calibration), Some(page)) = (&calibration, &calibrated) {
470        text.push('\n');
471        text.push_str(&evaluate::calibration_text(calibration, "the page"));
472        text.push_str(&format!("\n# the page, calibrated\n\n{page}"));
473    }
474    if refused {
475        text.push_str(&format!("\n{why}\n"));
476    }
477    if !host.live {
478        text.push_str(SIMULATED);
479    }
480    Ok(ToolResult::ok(text))
481}
482
483/// The host's `ask`, as the eval runner wants it: a function from a session to an outcome.
484fn asker(
485    host: &Host,
486) -> impl Fn(Session) -> Pin<Box<dyn Future<Output = Outcome> + Send>> + Send + Sync + Clone + 'static
487{
488    let send = Arc::clone(&host.ask);
489    move |one: Session| {
490        let send = Arc::clone(&send);
491        Box::pin(async move { send(one).await.outcome })
492    }
493}
494
495/// `jev_eval` with `compare`: both pages over the same cases, labelled `a` and `b`.
496async fn compared(
497    args: &Value,
498    host: &Host,
499    second: &str,
500    (a, model_a): (Session, String),
501    threshold: f64,
502    concurrency: usize,
503    rates: Option<Rates>,
504) -> Result<ToolResult, String> {
505    let mut b = headless::load(second).map_err(|e| format!("compare: {e}"))?;
506    // A model named in the call overrides both pages, the way --model does on the command line.
507    if let Some(model) = optional_string(args, "model")? {
508        b.model = Some(model);
509    }
510    let model_b = b.model.clone().unwrap_or_else(|| host.model.clone());
511    let labels = evaluate::Labels { a: "a", b: "b" };
512    let (cases_a, cases_b) =
513        evaluate::parse_compare_cases(&string_arg(args, "cases")?, &a, &b, labels)?;
514    let json_wanted = bool_arg(args, "json")?;
515    let (outcomes_a, outcomes_b) = evaluate::run_compare(
516        evaluate::Leg {
517            session: &a,
518            cases: &cases_a,
519            ask: asker(host),
520        },
521        evaluate::Leg {
522            session: &b,
523            cases: &cases_b,
524            ask: asker(host),
525        },
526        concurrency,
527    )
528    .await;
529    let comparison = evaluate::compare(
530        evaluate::Side {
531            label: "a",
532            session: &a,
533            cases: &cases_a,
534            outcomes: &outcomes_a,
535            model: &model_a,
536        },
537        evaluate::Side {
538            label: "b",
539            session: &b,
540            cases: &cases_b,
541            outcomes: &outcomes_b,
542            model: &model_b,
543        },
544        evaluate::CompareOptions { threshold, rates },
545    );
546    if json_wanted {
547        let body = serde_json::to_string_pretty(&evaluate::compare_json(&comparison))
548            .map_err(|e| e.to_string())?;
549        return Ok(ToolResult::ok(format!("{body}\n")));
550    }
551    let mut text = evaluate::compare_text(&comparison);
552    if !host.live {
553        text.push_str(SIMULATED);
554    }
555    Ok(ToolResult::ok(text))
556}
557
558/// A preset as the page it builds, with its name and what it is for above it.
559fn preset_page(preset: &presets::Preset) -> String {
560    format!(
561        "# {} — {}\n\n{}",
562        preset.name,
563        preset.about,
564        sketch::render(&presets::to_session(preset))
565    )
566}
567
568fn preset_pages(args: &Value) -> Result<ToolResult, String> {
569    match optional_string(args, "name")? {
570        Some(name) => {
571            let Some(preset) = presets::find(&name) else {
572                let names = presets::PRESETS
573                    .iter()
574                    .map(|p| p.name)
575                    .collect::<Vec<_>>()
576                    .join(", ");
577                return Err(format!("no preset {name:?}; there is {names}."));
578            };
579            Ok(ToolResult::ok(preset_page(preset)))
580        }
581        None => Ok(ToolResult::ok(
582            presets::PRESETS
583                .iter()
584                .map(preset_page)
585                .collect::<Vec<_>>()
586                .join("\n\n"),
587        )),
588    }
589}
590
591/// Run one tool. Argument problems come back as an error result, not as a JSON-RPC failure.
592pub async fn call(name: &str, args: &Value, host: &Host) -> ToolResult {
593    let outcome = match name {
594        "jev_notation" => Ok(ToolResult::ok(SKILL_MD)),
595        "jev_check" => string_arg(args, "page").map(|page| match headless::check_text(&page) {
596            Ok(summary) => ToolResult::ok(format!("{summary}\n")),
597            Err(problems) => ToolResult::failed(problems),
598        }),
599        "jev_request" => session_arg(args, host)
600            .map(|(session, model)| ToolResult::ok(headless::request_text(&session, &model))),
601        "jev_cost" => rates_arg(args, host).and_then(|rates| {
602            session_arg(args, host).map(|(session, model)| {
603                ToolResult::ok(headless::cost_text(&session, &model, rates))
604            })
605        }),
606        "jev_ask" => ask(args, host).await,
607        "jev_eval" => score(args, host).await,
608        "jev_code" => threshold_arg(args).and_then(|threshold| {
609            session_arg(args, host).map(|(session, model)| {
610                ToolResult::ok(headless::code_text(&session, &model, threshold))
611            })
612        }),
613        "jev_presets" => preset_pages(args),
614        other => {
615            return ToolResult::failed(format!("No tool named {other:?}. tools/list has them."));
616        }
617    };
618    match outcome {
619        Ok(result) => result,
620        Err(why) => ToolResult::failed(format!("{name}: {why}")),
621    }
622}
623
624fn reply(id: Value, result: Value) -> Value {
625    json!({ "jsonrpc": "2.0", "id": id, "result": result })
626}
627
628fn fault(id: Value, code: i64, message: String) -> Value {
629    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
630}
631
632/// What `initialize` answers with: what we speak, what we can do, who we are.
633fn greeting(params: &Value, host: &Host) -> Value {
634    let asked = params.get("protocolVersion").and_then(Value::as_str);
635    let version = match asked {
636        Some(asked) if KNOWN_PROTOCOLS.contains(&asked) => asked,
637        _ => PROTOCOL_VERSION,
638    };
639    let instructions = format!(
640        "jev shapes and sends TypeSafe AI System One questions. Call jev_notation first to learn \
641         the page notation, jev_check to make sure a page parses, jev_cost before anything large, \
642         then jev_ask or jev_eval.{}",
643        if host.live {
644            ""
645        } else {
646            " This server has no API key: every answer is simulated."
647        }
648    );
649    json!({
650        "protocolVersion": version,
651        "capabilities": { "tools": { "listChanged": false } },
652        "serverInfo": { "name": SERVER_NAME, "title": "jev", "version": host.version },
653        "instructions": instructions,
654    })
655}
656
657/// Answer one JSON-RPC message.
658///
659/// Returns `None` for a notification — a message with no `id` gets no reply, which is the one rule
660/// of the protocol that a hand-written server usually gets wrong.
661pub async fn handle(message: &Value, host: &Host) -> Option<Value> {
662    let Some(object) = message.as_object() else {
663        return Some(fault(
664            Value::Null,
665            INVALID_REQUEST,
666            "Expected a JSON-RPC object.".to_owned(),
667        ));
668    };
669    let id = match object.get("id") {
670        None | Some(Value::Null) => None,
671        Some(id) => Some(id.clone()),
672    };
673    let Some(method) = object.get("method").and_then(Value::as_str) else {
674        return id.map(|id| fault(id, INVALID_REQUEST, "No method named.".to_owned()));
675    };
676    // Nothing this server keeps state for; the handshake's `initialized` is the usual one.
677    let id = id?;
678    let empty = Value::Object(Map::new());
679    let params = object.get("params").unwrap_or(&empty);
680
681    Some(match method {
682        "initialize" => reply(id, greeting(params, host)),
683        "ping" => reply(id, json!({})),
684        "tools/list" => reply(id, json!({ "tools": tools() })),
685        "tools/call" => {
686            let Some(name) = params.get("name").and_then(Value::as_str) else {
687                return Some(fault(
688                    id,
689                    INVALID_PARAMS,
690                    "tools/call needs a tool name.".to_owned(),
691                ));
692            };
693            let args = params.get("arguments").unwrap_or(&empty).clone();
694            reply(id, call(name, &args, host).await.to_value())
695        }
696        "resources/list" => reply(id, json!({ "resources": [] })),
697        "prompts/list" => reply(id, json!({ "prompts": [] })),
698        other => fault(id, METHOD_NOT_FOUND, format!("Unknown method {other:?}.")),
699    })
700}
701
702/// One line of stdio: parse it, answer it, hand back the line to write — or nothing.
703pub async fn handle_line(line: &str, host: &Host) -> Option<String> {
704    if line.trim().is_empty() {
705        return None;
706    }
707    let message: Value = match serde_json::from_str(line) {
708        Ok(value) => value,
709        Err(e) => {
710            return Some(
711                fault(
712                    Value::Null,
713                    PARSE_ERROR,
714                    format!("Could not parse the message: {e}"),
715                )
716                .to_string(),
717            );
718        }
719    };
720    handle(&message, host).await.map(|value| value.to_string())
721}