Skip to main content

pimalaya_cli/
mcp.rs

1//! A Model Context Protocol server over stdio: the JSON-RPC answers every
2//! server gives alike, and the tool results built from what a command
3//! prints.
4//!
5//! A server reads one JSON-RPC message per line of stdin and writes one
6//! per line of stdout, which carries protocol messages only. [`read`]
7//! answers a line itself, the protocol errors, `initialize`, `ping` and
8//! `tools/list` included, or hands the binary a checked [`ToolCall`]. The
9//! binary runs it, usually a command printing into a [`CapturePrinter`],
10//! and answers a [`ToolResult`] through [`ToolCall::respond`].
11//!
12//! A server with nothing else to wait for runs [`serve`], a blocking loop
13//! over stdin. One with background events (a listener, a poll) reads
14//! stdin on a thread with [`spawn_stdin_reader`], feeding its own
15//! channel, and answers each line with [`answer`] and [`send`]. Either way
16//! stdin is claimed ([`claim_stdin`]), so a command reading its input
17//! there is refused, and [`exit_on_failure`] reports a failure on stderr.
18//!
19//! The [`grammar`] module derives a tool's input from a clap command.
20//!
21//! Refs: <https://modelcontextprotocol.io/specification/2025-06-18>
22//!
23//! [`CapturePrinter`]: crate::printer::CapturePrinter
24//! [`claim_stdin`]: crate::session::claim_stdin
25
26pub mod grammar;
27
28use std::{
29    io::{self, BufRead, Write},
30    mem, process, str,
31    sync::mpsc::Sender,
32    thread::{self, JoinHandle},
33};
34
35use anyhow::{Context, Error, Result};
36use serde::Serialize;
37use serde_json::{Map, Value, json};
38
39use crate::{
40    error::{
41        ErrorCode, ErrorReport, NoErrorCode, failure_status, partial_outcome_of, usage_invalid,
42    },
43    printer::CapturePrinter,
44    session::claim_stdin,
45};
46
47/// The MCP revisions the server speaks, latest first.
48///
49/// 2025-06-18 is the first carrying `structuredContent` and
50/// `outputSchema`; 2025-03-26 asks for batches, which the server refuses.
51pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18"];
52
53/// The JSON-RPC code of a message that is not JSON.
54pub const PARSE_ERROR: i64 = -32700;
55
56/// The JSON-RPC code of a message that is no valid request.
57pub const INVALID_REQUEST: i64 = -32600;
58
59/// The JSON-RPC code of an unknown method.
60pub const METHOD_NOT_FOUND: i64 = -32601;
61
62/// The JSON-RPC code of a request whose parameters are wrong.
63pub const INVALID_PARAMS: i64 = -32602;
64
65/// What a server tells the client at `initialize`.
66#[derive(Clone, Copy, Debug)]
67pub struct ServerInfo<'a> {
68    /// The binary name.
69    pub name: &'a str,
70    /// The binary version, usually `env!("CARGO_PKG_VERSION")`.
71    pub version: &'a str,
72    /// How to use the tools, for the client to hand its model.
73    pub instructions: Option<&'a str>,
74}
75
76/// What a line read off stdin asks of the server.
77#[derive(Debug)]
78pub enum Incoming {
79    /// The answer to write back as it is.
80    Reply(Value),
81    /// A tool call to run, then answer with [`ToolCall::respond`].
82    Call(ToolCall),
83    /// Nothing to answer: a notification or a blank line.
84    Ignore,
85}
86
87/// A `tools/call` request, checked: the tool is listed and the arguments
88/// are an object whose keys the tool declares.
89#[derive(Clone, Debug)]
90pub struct ToolCall {
91    /// The request id, to answer with.
92    pub id: Value,
93    /// The tool name.
94    pub name: String,
95    /// The tool arguments, empty when the call gave none.
96    pub arguments: Map<String, Value>,
97}
98
99impl ToolCall {
100    /// The JSON-RPC response carrying `result`.
101    pub fn respond(&self, result: ToolResult) -> Value {
102        json!({ "jsonrpc": "2.0", "id": self.id, "result": result })
103    }
104}
105
106/// The result of a tool call.
107///
108/// A failure is a result with `isError` rather than a protocol error, so
109/// the model reads it.
110#[derive(Clone, Debug, Default, Serialize)]
111#[serde(rename_all = "camelCase")]
112pub struct ToolResult {
113    /// The content items, each `{type, ...}`, a text one built by [`text`].
114    pub content: Vec<Value>,
115    /// The structured result, an object.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub structured_content: Option<Value>,
118    /// Whether the call failed.
119    #[serde(skip_serializing_if = "is_false")]
120    pub is_error: bool,
121}
122
123impl ToolResult {
124    /// A success holding `value` as text, and as `structuredContent` when
125    /// it is an object.
126    pub fn success(value: Value) -> Self {
127        Self {
128            content: vec![text(value.to_string())],
129            structured_content: value.is_object().then_some(value),
130            is_error: false,
131        }
132    }
133
134    /// A failure holding the failure object `report` as text.
135    pub fn failure(report: Value) -> Self {
136        Self {
137            content: vec![text(report.to_string())],
138            structured_content: None,
139            is_error: true,
140        }
141    }
142
143    /// A failure holding the failure object of `err`, with the code of
144    /// its chain.
145    pub fn error<C: ErrorCode>(err: Error) -> Self {
146        Self::failure(error_object::<C>(err))
147    }
148
149    /// What a command printed into `printer`, given how it ended.
150    ///
151    /// The events come first, one text item each, then the single value
152    /// the command printed. A partial outcome keeps that result and sets
153    /// `isError`; a failure, or a count of values other than one, is a
154    /// failure object.
155    pub fn printed<C: ErrorCode>(mut printer: CapturePrinter, result: Result<()>) -> Self {
156        let partial = match result {
157            Ok(()) => false,
158            Err(err) if partial_outcome_of(&err).is_some() => true,
159            Err(err) => return Self::error::<C>(err),
160        };
161
162        let events = mem::take(&mut printer.events);
163
164        let value = match printer.into_single() {
165            Ok(value) => value,
166            Err(err) => return Self::error::<C>(err),
167        };
168
169        let mut result = Self::success(value);
170        let events = events.into_iter().map(|event| text(event.to_string()));
171        result.content.splice(0..0, events);
172        result.is_error = partial;
173        result
174    }
175}
176
177/// A line of stdin as a [`spawn_stdin_reader`] thread reads it.
178#[derive(Debug)]
179pub enum Input {
180    /// One line, its newline included, as bytes.
181    Line(Vec<u8>),
182    /// stdin closed, or failed.
183    Eof,
184}
185
186/// Answers one line read off stdin, or hands the tool call it holds to
187/// the binary.
188///
189/// `tools` are the definitions `tools/list` answers. A message that is
190/// not UTF-8 or not JSON answers -32700, one that is not a JSON object
191/// (a batch included), whose `jsonrpc` is not `"2.0"`, whose `id` is not
192/// a string or a number or that has no `method` answers -32600, an
193/// unknown method -32601. A `tools/call` whose params are not an object,
194/// whose tool is missing or not listed, or whose arguments are not an
195/// object answers -32602; an argument its tool's `inputSchema` does not
196/// declare, when it sets `additionalProperties: false`, answers a failed
197/// tool result naming the declared ones.
198pub fn read(line: &[u8], info: &ServerInfo, tools: &[Value]) -> Incoming {
199    let Ok(line) = str::from_utf8(line) else {
200        let message = "Parse error: the message is not UTF-8";
201        return Incoming::Reply(error(Value::Null, PARSE_ERROR, message));
202    };
203
204    if line.trim().is_empty() {
205        return Incoming::Ignore;
206    }
207
208    let message = match serde_json::from_str(line) {
209        Ok(Value::Object(message)) => message,
210        Ok(_) => {
211            let message =
212                "Invalid request: a message is one JSON object, batches are not supported";
213            return Incoming::Reply(error(Value::Null, INVALID_REQUEST, message));
214        }
215        Err(err) => {
216            let message = format!("Parse error: {err}");
217            return Incoming::Reply(error(Value::Null, PARSE_ERROR, message));
218        }
219    };
220
221    let id = match message.get("id") {
222        None => None,
223        Some(id @ (Value::String(_) | Value::Number(_))) => Some(id.clone()),
224        Some(_) => {
225            let message = "Invalid request: the id is not a string or a number";
226            return Incoming::Reply(error(Value::Null, INVALID_REQUEST, message));
227        }
228    };
229
230    let invalid = |message: &str| {
231        let id = id.clone().unwrap_or(Value::Null);
232        Incoming::Reply(error(id, INVALID_REQUEST, message))
233    };
234
235    if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
236        return invalid("Invalid request: jsonrpc is not \"2.0\"");
237    }
238
239    // A response is invalid too: the server sends no request.
240    let Some(method) = message.get("method").and_then(Value::as_str) else {
241        return invalid("Invalid request: the method is missing or not a string");
242    };
243
244    let Some(id) = id else {
245        return Incoming::Ignore;
246    };
247
248    let params = message.get("params");
249
250    let result = match method {
251        "initialize" => initialize(info, params),
252        "ping" => json!({}),
253        "tools/list" => json!({ "tools": tools }),
254        "tools/call" => return call(id, params, tools),
255        method => {
256            let message = format!("Method not found: {method}");
257            return Incoming::Reply(error(id, METHOD_NOT_FOUND, message));
258        }
259    };
260
261    Incoming::Reply(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
262}
263
264/// Answers one line read off stdin, running the tool call it holds with
265/// `run`; `None` when nothing is to be answered.
266pub fn answer(
267    line: &[u8],
268    info: &ServerInfo,
269    tools: &[Value],
270    run: impl FnOnce(&ToolCall) -> ToolResult,
271) -> Option<Value> {
272    match read(line, info, tools) {
273        Incoming::Reply(reply) => Some(reply),
274        Incoming::Call(call) => Some(call.respond(run(&call))),
275        Incoming::Ignore => None,
276    }
277}
278
279/// Writes one message as one line, flushed.
280pub fn send(output: &mut impl Write, message: &Value) -> Result<()> {
281    serde_json::to_writer(&mut *output, message).context("Cannot write a message to stdout")?;
282    writeln!(output)?;
283    output.flush()?;
284    Ok(())
285}
286
287/// Serves the requests of stdin until EOF, answering each on stdout,
288/// each tool call run with `run`.
289///
290/// It claims stdin first.
291pub fn serve(
292    info: &ServerInfo,
293    tools: &[Value],
294    run: impl FnMut(&ToolCall) -> ToolResult,
295) -> Result<()> {
296    claim_stdin();
297    serve_on(&mut io::stdin().lock(), &mut io::stdout(), info, tools, run)
298}
299
300/// Reads stdin on a thread of its own, sending each line, then EOF, to
301/// `events` as `wrap` turns them into the binary's event type.
302///
303/// It claims stdin first. The thread ends at EOF, on a read failure, or
304/// once nobody receives.
305pub fn spawn_stdin_reader<T: Send + 'static>(
306    events: Sender<T>,
307    wrap: impl Fn(Input) -> T + Send + 'static,
308) -> JoinHandle<()> {
309    claim_stdin();
310    thread::spawn(move || read_lines(&mut io::stdin().lock(), &events, wrap))
311}
312
313/// Unwraps the result of a server, or prints its failure as text on
314/// stderr and exits with status 1, or 2 for a refused command line, or
315/// with the status of a partial outcome.
316///
317/// stdout carries protocol messages only, so a server failing before its
318/// loop (resolving its account) or in it reports here rather than
319/// through the printer.
320pub fn exit_on_failure<C: ErrorCode, T>(result: Result<T>) -> T {
321    match result {
322        Ok(value) => value,
323        Err(err) => {
324            if let Some(outcome) = partial_outcome_of(&err) {
325                process::exit(outcome.status());
326            }
327
328            let status = failure_status(&err);
329            eprint!("{}", ErrorReport::<C>::from(err));
330            process::exit(status);
331        }
332    }
333}
334
335/// `schema` as a tool's `outputSchema`, when its root is typed `object`.
336///
337/// MCP takes an object root only: a reference, an `anyOf` or a `oneOf`
338/// root is refused even when every alternative is an object, which the
339/// registry's `non_object_roots` accepts as the shape of a printed value.
340pub fn tool_output_schema(schema: &Value) -> Option<&Value> {
341    (schema["type"] == "object").then_some(schema)
342}
343
344/// The failure object of `err`, with the code of its chain, as a failed
345/// tool call holds it and as a binary keeps it to report later.
346pub fn error_object<C: ErrorCode>(err: Error) -> Value {
347    serde_json::to_value(ErrorReport::<C>::from(err))
348        .unwrap_or_else(|err| Value::from(err.to_string()))
349}
350
351/// The text content item `{"type": "text", "text": text}` of a tool
352/// result.
353pub fn text(text: impl Into<String>) -> Value {
354    json!({ "type": "text", "text": text.into() })
355}
356
357/// The message refusing an argument `key` that `owner` does not declare,
358/// naming the `known` ones.
359pub(crate) fn unknown_argument<'a>(
360    key: &str,
361    owner: &str,
362    known: impl IntoIterator<Item = &'a str>,
363) -> Error {
364    let known: Vec<String> = known.into_iter().map(|name| format!("`{name}`")).collect();
365
366    if known.is_empty() {
367        usage_invalid(format!("Unknown argument `{key}`, {owner} takes none"))
368    } else {
369        usage_invalid(format!(
370            "Unknown argument `{key}`, {owner} takes {}",
371            known.join(", ")
372        ))
373    }
374}
375
376/// Serves the requests of `input` until EOF, answering each on `output`.
377fn serve_on(
378    input: &mut impl BufRead,
379    output: &mut impl Write,
380    info: &ServerInfo,
381    tools: &[Value],
382    mut run: impl FnMut(&ToolCall) -> ToolResult,
383) -> Result<()> {
384    let mut line = Vec::new();
385
386    loop {
387        line.clear();
388
389        let read = input.read_until(b'\n', &mut line);
390        if read.context("Cannot read a message from stdin")? == 0 {
391            return Ok(());
392        }
393
394        if let Some(response) = answer(&line, info, tools, &mut run) {
395            send(output, &response)?;
396        }
397    }
398}
399
400/// Sends each line of `input`, then EOF, to `events`.
401fn read_lines<T>(input: &mut impl BufRead, events: &Sender<T>, wrap: impl Fn(Input) -> T) {
402    loop {
403        let mut line = Vec::new();
404
405        match input.read_until(b'\n', &mut line) {
406            Ok(0) | Err(_) => break,
407            Ok(_) => {
408                if events.send(wrap(Input::Line(line))).is_err() {
409                    return;
410                }
411            }
412        }
413    }
414
415    let _ = events.send(wrap(Input::Eof));
416}
417
418/// The answer to `initialize`: the client's version when the server
419/// speaks it, its latest otherwise.
420fn initialize(info: &ServerInfo, params: Option<&Value>) -> Value {
421    let asked = params
422        .and_then(|params| params.get("protocolVersion"))
423        .and_then(Value::as_str);
424
425    let version = asked
426        .filter(|asked| PROTOCOL_VERSIONS.contains(asked))
427        .unwrap_or(PROTOCOL_VERSIONS[0]);
428
429    let mut result = json!({
430        "protocolVersion": version,
431        "capabilities": { "tools": { "listChanged": false } },
432        "serverInfo": { "name": info.name, "version": info.version },
433    });
434
435    if let Some(instructions) = info.instructions {
436        result["instructions"] = Value::from(instructions);
437    }
438
439    result
440}
441
442/// Checks a `tools/call` request against the listed `tools`.
443fn call(id: Value, params: Option<&Value>, tools: &[Value]) -> Incoming {
444    let refuse = |id, message: String| Incoming::Reply(error(id, INVALID_PARAMS, message));
445
446    let Some(Value::Object(params)) = params else {
447        return refuse(id, String::from("Invalid params: not an object"));
448    };
449
450    let Some(name) = params.get("name").and_then(Value::as_str) else {
451        return refuse(id, String::from("Missing tool name"));
452    };
453
454    let Some(tool) = tools.iter().find(|tool| tool["name"] == name) else {
455        return refuse(id, format!("Unknown tool: {name}"));
456    };
457
458    let arguments = match params.get("arguments") {
459        None | Some(Value::Null) => Map::new(),
460        Some(Value::Object(arguments)) => arguments.clone(),
461        Some(_) => return refuse(id, String::from("Arguments must be an object")),
462    };
463
464    let schema = &tool["inputSchema"];
465
466    if schema["additionalProperties"] == false {
467        let properties = schema["properties"].as_object();
468        let declared = |key: &String| properties.is_some_and(|known| known.contains_key(key));
469
470        if let Some(key) = arguments.keys().find(|key| !declared(key)) {
471            let known = properties.into_iter().flat_map(|known| known.keys());
472            let err = unknown_argument(key, name, known.map(String::as_str));
473            let result = ToolResult::error::<NoErrorCode>(err);
474            return Incoming::Reply(json!({ "jsonrpc": "2.0", "id": id, "result": result }));
475        }
476    }
477
478    Incoming::Call(ToolCall {
479        id,
480        name: name.to_owned(),
481        arguments,
482    })
483}
484
485/// A JSON-RPC error response.
486fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
487    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message.into() } })
488}
489
490fn is_false(value: &bool) -> bool {
491    !value
492}
493
494#[cfg(test)]
495mod tests {
496    use std::{fmt, sync::mpsc};
497
498    use anyhow::{Error, anyhow};
499    use schemars::JsonSchema;
500    use serde::Serialize;
501    use serde_json::{Value, json};
502
503    use crate::{
504        clap::commands::json_schema::output_schema,
505        error::PartialOutcome,
506        mcp::*,
507        printer::{CapturePrinter, Printer},
508    };
509
510    const INFO: ServerInfo = ServerInfo {
511        name: "app",
512        version: "1.2.3",
513        instructions: None,
514    };
515
516    fn tools() -> Vec<Value> {
517        vec![
518            json!({
519                "name": "item_get",
520                "inputSchema": {
521                    "type": "object",
522                    "properties": { "id": { "type": "string" }, "full": { "type": "boolean" } },
523                    "required": ["id"],
524                    "additionalProperties": false,
525                },
526            }),
527            json!({
528                "name": "item_list",
529                "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
530            }),
531            json!({ "name": "open", "inputSchema": { "type": "object" } }),
532        ]
533    }
534
535    fn reply(line: &str) -> Value {
536        match read(line.as_bytes(), &INFO, &tools()) {
537            Incoming::Reply(reply) => reply,
538            incoming => panic!("not a reply: {incoming:?}"),
539        }
540    }
541
542    fn code(reply: &Value) -> i64 {
543        reply["error"]["code"].as_i64().unwrap_or_default()
544    }
545
546    #[test]
547    fn a_message_that_is_not_json_is_a_parse_error() {
548        let reply = reply("{\"jsonrpc\": ");
549        assert_eq!(code(&reply), PARSE_ERROR, "{reply}");
550        assert_eq!(reply["id"], Value::Null);
551        assert_eq!(reply["jsonrpc"], "2.0");
552    }
553
554    #[test]
555    fn bytes_that_are_not_utf8_are_a_parse_error() {
556        let Incoming::Reply(reply) = read(b"{\"id\": \"\xff\"}\n", &INFO, &[]) else {
557            panic!("no reply");
558        };
559
560        assert_eq!(code(&reply), PARSE_ERROR, "{reply}");
561        assert_eq!(reply["id"], Value::Null);
562    }
563
564    #[test]
565    fn a_batch_or_a_scalar_is_an_invalid_request() {
566        for line in [
567            r#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#,
568            "3",
569            "\"ping\"",
570            "null",
571        ] {
572            let reply = reply(line);
573            assert_eq!(code(&reply), INVALID_REQUEST, "{line}: {reply}");
574            assert_eq!(reply["id"], Value::Null, "{line}");
575        }
576    }
577
578    #[test]
579    fn an_invalid_request_keeps_its_id_when_valid() {
580        let wrong_version = reply(r#"{"jsonrpc":"1.0","id":7,"method":"ping"}"#);
581        assert_eq!(code(&wrong_version), INVALID_REQUEST);
582        assert_eq!(wrong_version["id"], 7);
583
584        let no_version = reply(r#"{"id":"a","method":"ping"}"#);
585        assert_eq!(code(&no_version), INVALID_REQUEST);
586        assert_eq!(no_version["id"], "a");
587
588        let response = reply(r#"{"jsonrpc":"2.0","id":8,"result":{}}"#);
589        assert_eq!(code(&response), INVALID_REQUEST);
590        assert_eq!(response["id"], 8);
591
592        let bad_id = reply(r#"{"jsonrpc":"2.0","id":{},"method":"ping"}"#);
593        assert_eq!(code(&bad_id), INVALID_REQUEST);
594        assert_eq!(bad_id["id"], Value::Null);
595    }
596
597    #[test]
598    fn a_notification_or_a_blank_line_gets_no_answer() {
599        for line in [
600            &br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#[..],
601            br#"{"jsonrpc":"2.0","method":"unknown"}"#,
602            b"  \r\n",
603        ] {
604            assert!(matches!(read(line, &INFO, &[]), Incoming::Ignore));
605        }
606    }
607
608    #[test]
609    fn an_unknown_method_is_not_found() {
610        let reply = reply(r#"{"jsonrpc":"2.0","id":1,"method":"resources/list"}"#);
611        assert_eq!(code(&reply), METHOD_NOT_FOUND, "{reply}");
612        assert_eq!(reply["id"], 1);
613    }
614
615    #[test]
616    fn ping_answers_an_empty_object() {
617        let reply = reply(r#"{"jsonrpc":"2.0","id":"p","method":"ping"}"#);
618        assert_eq!(reply, json!({ "jsonrpc": "2.0", "id": "p", "result": {} }));
619    }
620
621    #[test]
622    fn initialize_echoes_a_supported_version() {
623        let line = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#;
624        let reply = reply(line);
625
626        assert_eq!(
627            reply["result"],
628            json!({
629                "protocolVersion": "2025-06-18",
630                "capabilities": { "tools": { "listChanged": false } },
631                "serverInfo": { "name": "app", "version": "1.2.3" },
632            })
633        );
634    }
635
636    #[test]
637    fn initialize_answers_the_latest_version_to_an_unsupported_one() {
638        let info = ServerInfo {
639            instructions: Some("Call item_list first."),
640            ..INFO
641        };
642
643        for params in [r#"{"protocolVersion":"2099-01-01"}"#, "{}", "null"] {
644            let line =
645                format!(r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{params}}}"#);
646            let Incoming::Reply(reply) = read(line.as_bytes(), &info, &[]) else {
647                panic!("no reply");
648            };
649
650            assert_eq!(reply["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
651            assert_eq!(reply["result"]["instructions"], "Call item_list first.");
652        }
653    }
654
655    #[test]
656    fn tools_list_answers_the_tools_given() {
657        let reply = reply(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
658        assert_eq!(reply["result"]["tools"], Value::from(tools()));
659    }
660
661    #[test]
662    fn a_call_with_bad_params_is_refused() {
663        for params in [
664            "",
665            r#","params":3"#,
666            r#","params":{}"#,
667            r#","params":{"name":"item_put"}"#,
668            r#","params":{"name":"item_get","arguments":[]}"#,
669        ] {
670            let line = format!(r#"{{"jsonrpc":"2.0","id":4,"method":"tools/call"{params}}}"#);
671            let reply = reply(&line);
672            assert_eq!(code(&reply), INVALID_PARAMS, "{line}: {reply}");
673            assert_eq!(reply["id"], 4);
674        }
675    }
676
677    #[test]
678    fn a_checked_call_is_handed_over() {
679        let line = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"item_get","arguments":{"id":"x"}}}"#;
680        let Incoming::Call(call) = read(line.as_bytes(), &INFO, &tools()) else {
681            panic!("no call");
682        };
683
684        assert_eq!(call.id, 5);
685        assert_eq!(call.name, "item_get");
686        assert_eq!(Value::from(call.arguments.clone()), json!({ "id": "x" }));
687
688        let line = r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"open","arguments":{"any":1}}}"#;
689        assert!(matches!(
690            read(line.as_bytes(), &INFO, &tools()),
691            Incoming::Call(_)
692        ));
693
694        let line = r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"item_list","arguments":null}}"#;
695        let Incoming::Call(call) = read(line.as_bytes(), &INFO, &tools()) else {
696            panic!("no call");
697        };
698        assert!(call.arguments.is_empty());
699    }
700
701    #[test]
702    fn an_undeclared_argument_is_a_failed_result_naming_the_declared_ones() {
703        let line = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"item_get","arguments":{"di":"x"}}}"#;
704        let unknown = reply(line);
705        let result = &unknown["result"];
706
707        assert_eq!(unknown["id"], 5);
708        assert_eq!(result["isError"], true, "{unknown}");
709
710        let report: Value =
711            serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap();
712        assert_eq!(
713            report["error"],
714            "Unknown argument `di`, item_get takes `full`, `id`"
715        );
716        assert_eq!(report["code"], "usage-invalid", "{report}");
717
718        let line = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"item_list","arguments":{"x":1}}}"#;
719        let none = reply(line);
720        assert!(none.to_string().contains("item_list takes none"), "{none}");
721    }
722
723    #[derive(Serialize, JsonSchema)]
724    struct Item {
725        id: u32,
726    }
727
728    impl fmt::Display for Item {
729        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
730            writeln!(f, "{}", self.id)
731        }
732    }
733
734    fn printed(values: &[u32], events: &[&str], result: Result<(), Error>) -> Value {
735        let mut printer = CapturePrinter::default();
736
737        for event in events {
738            printer.event(json!({ "event": event })).unwrap();
739        }
740
741        for id in values {
742            printer.out(Item { id: *id }).unwrap();
743        }
744
745        serde_json::to_value(ToolResult::printed::<NoErrorCode>(printer, result)).unwrap()
746    }
747
748    #[test]
749    fn a_printed_value_is_structured_after_its_events() {
750        assert_eq!(
751            printed(&[1], &["started"], Ok(())),
752            json!({
753                "content": [
754                    { "type": "text", "text": r#"{"event":"started"}"# },
755                    { "type": "text", "text": r#"{"id":1}"# },
756                ],
757                "structuredContent": { "id": 1 },
758            })
759        );
760    }
761
762    #[test]
763    fn a_partial_outcome_keeps_its_result_as_an_error() {
764        let result = printed(&[1], &[], Err(PartialOutcome::default().into()));
765
766        assert_eq!(result["structuredContent"], json!({ "id": 1 }));
767        assert_eq!(result["isError"], true);
768    }
769
770    #[test]
771    fn a_failure_or_a_wrong_count_is_a_failure_object() {
772        for (values, outcome) in [
773            (&[][..], Ok(())),
774            (&[1, 2][..], Ok(())),
775            (&[1][..], Err(anyhow!("Boom"))),
776        ] {
777            let expected = match &outcome {
778                Ok(()) => format!("The command printed {} values, one expected", values.len()),
779                Err(err) => err.to_string(),
780            };
781
782            let result = printed(values, &["started"], outcome);
783            assert_eq!(result["isError"], true, "{result}");
784            assert!(result.get("structuredContent").is_none(), "{result}");
785            assert_eq!(result["content"].as_array().unwrap().len(), 1, "{result}");
786
787            let text = result["content"][0]["text"].as_str().unwrap();
788            let report: Value = serde_json::from_str(text).unwrap();
789            assert_eq!(report["error"], expected);
790            assert_eq!(report["code"], Value::Null);
791        }
792    }
793
794    #[test]
795    fn a_value_that_is_not_an_object_is_text_alone() {
796        let result = serde_json::to_value(ToolResult::success(json!([1]))).unwrap();
797        assert_eq!(
798            result,
799            json!({ "content": [{ "type": "text", "text": "[1]" }] })
800        );
801    }
802
803    #[test]
804    fn an_output_schema_needs_an_object_root() {
805        let object = json!({ "type": "object", "properties": {} });
806        assert_eq!(tool_output_schema(&object), Some(&object));
807
808        for schema in [
809            json!({ "$ref": "#/$defs/A", "$defs": { "A": { "type": "object" } } }),
810            json!({ "anyOf": [{ "type": "object" }, { "type": "object" }] }),
811            json!({ "oneOf": [{ "type": "object" }] }),
812            json!({ "type": "array" }),
813            json!({ "type": ["object", "null"] }),
814        ] {
815            assert_eq!(tool_output_schema(&schema), None, "{schema}");
816        }
817
818        let schema = output_schema::<Item>();
819        assert!(tool_output_schema(&schema).is_some(), "{schema}");
820    }
821
822    #[test]
823    fn the_loop_answers_each_line_and_runs_each_call() {
824        let input = concat!(
825            r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#,
826            "\n\n",
827            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
828            "\n",
829            r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"item_get","arguments":{"id":"x"}}}"#,
830            "\n\u{0}\n",
831            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"item_list"}}"#,
832        );
833
834        let mut output = Vec::new();
835        let mut calls = Vec::new();
836
837        serve_on(
838            &mut input.as_bytes(),
839            &mut output,
840            &INFO,
841            &tools(),
842            |call| {
843                calls.push(call.name.clone());
844                ToolResult::success(json!({ "called": call.name }))
845            },
846        )
847        .unwrap();
848
849        let output = String::from_utf8(output).unwrap();
850        let replies: Vec<Value> = output
851            .lines()
852            .map(|line| serde_json::from_str(line).unwrap())
853            .collect();
854
855        assert_eq!(calls, ["item_get", "item_list"]);
856        assert_eq!(replies.len(), 4, "{output}");
857        assert_eq!(replies[0]["id"], 1);
858        assert_eq!(
859            replies[1]["result"]["structuredContent"]["called"],
860            "item_get"
861        );
862        assert_eq!(code(&replies[2]), PARSE_ERROR);
863        assert_eq!(replies[3]["id"], 3);
864    }
865
866    #[test]
867    fn the_reader_sends_each_line_then_eof() {
868        let (events, inbox) = mpsc::channel();
869        read_lines(&mut &b"a\n\xff\nb"[..], &events, |input| input);
870        drop(events);
871
872        let lines: Vec<Option<Vec<u8>>> = inbox
873            .iter()
874            .map(|input| match input {
875                Input::Line(line) => Some(line),
876                Input::Eof => None,
877            })
878            .collect();
879
880        assert_eq!(
881            lines,
882            [
883                Some(b"a\n".to_vec()),
884                Some(b"\xff\n".to_vec()),
885                Some(b"b".to_vec()),
886                None
887            ]
888        );
889    }
890
891    #[test]
892    fn a_response_carries_the_call_id() {
893        let call = ToolCall {
894            id: json!("c1"),
895            name: String::from("item_get"),
896            arguments: Map::new(),
897        };
898
899        let response = call.respond(ToolResult::success(json!({ "id": 1 })));
900        assert_eq!(response["jsonrpc"], "2.0");
901        assert_eq!(response["id"], "c1");
902        assert_eq!(response["result"]["structuredContent"], json!({ "id": 1 }));
903    }
904}