OXYGENHUB

Coverage / crates/oxy-core/src/mcp.rs 93.3%

196 of 210 lines run, 34 of 47 functions · run #427 on main · run missed

1
//! A Model Context Protocol server over stdio (oxygen#159): JSON-RPC 2.0,
2
//! one message per line, as MCP's stdio transport has it. This is only the
3
//! protocol: `initialize`, `ping`, `tools/list` and `tools/call`, with the
4
//! tools themselves supplied by the caller (`oxy mcp`, and `oh mcp` in the
5
//! hub, which reaches this module through its pinned oxygen).
6
//!
7
//! Nothing is written to stdout but protocol messages: a tool's own
8
//! output goes into its result.
9
10
use serde_json::{json, Value};
11
use std::io::{BufRead, Write};
12
13
/// The protocol versions this server speaks, newest first. A client asking
14
/// for one of them gets it back; any other gets the newest.
15
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
16
17
/// One tool as `tools/list` describes it.
18
#[derive(Clone, Debug)]
19
pub struct Tool {
20
    pub name: String,
21
    pub description: String,
22
    /// A JSON Schema object for the arguments.
23
    pub input_schema: Value,
24
    /// Reads only: no change to the repository, the remote or the hub.
25
    pub read_only: bool,
26
    /// May lose work or history (undo, abandon, deletes).
27
    pub destructive: bool,
28
}
29
30
impl Tool {
3138×
    fn describe(&self) -> Value {
3238×
        json!({
3338×
            "name": self.name,
3438×
            "description": self.description,
3538×
            "inputSchema": self.input_schema,
3638×
            "annotations": {
3738×
                "readOnlyHint": self.read_only,
3838×
                "destructiveHint": self.destructive,
39
            },
40
        })
4138×
    }
42
}
43
44
/// What a tool call produced: text for the model to read, the same as JSON
45
/// when there is some, and whether it failed. A failed tool is a result, not
46
/// a protocol error, so the model sees why.
47
#[derive(Clone, Debug, Default, PartialEq)]
48
pub struct ToolOutput {
49
    pub text: String,
50
    pub structured: Option<Value>,
51
    pub is_error: bool,
52
}
53
54
impl ToolOutput {
555×
    pub fn ok(text: impl Into<String>) -> ToolOutput {
565×
        ToolOutput {
575×
            text: text.into(),
585×
            structured: None,
595×
            is_error: false,
605×
        }
615×
    }
62
632×
    pub fn error(text: impl Into<String>) -> ToolOutput {
642×
        ToolOutput {
652×
            text: text.into(),
662×
            structured: None,
672×
            is_error: true,
682×
        }
692×
    }
70
718×
    fn to_json(&self) -> Value {
728×
        let mut out = json!({
738×
            "content": [{ "type": "text", "text": self.text }],
748×
            "isError": self.is_error,
75
        });
76
        // Structured content must be an object; an array is wrapped.
778×
        if let Some(s) = &self.structured {
784×
            let obj = if s.is_object() {
793×
                s.clone()
80
            } else {
811×
                json!({ "result": s })
82
            };
834×
            out["structuredContent"] = obj;
844×
        }
858×
        out
868×
    }
87
}
88
89
/// The tools a server offers.
90
pub trait Tools {
91
    fn list(&self) -> Vec<Tool>;
92
    /// Run a tool. `None` when no tool has that name.
93
    fn call(&self, name: &str, args: &Value) -> Option<ToolOutput>;
94
}
95
96
/// Who the server is, for `initialize`.
97
#[derive(Clone, Debug)]
98
pub struct ServerInfo {
99
    pub name: String,
100
    pub version: String,
101
    /// Shown to the model: what the server is for and how to use it safely.
102
    pub instructions: String,
103
}
104
105
const PARSE_ERROR: i64 = -32700;
106
const INVALID_REQUEST: i64 = -32600;
107
const METHOD_NOT_FOUND: i64 = -32601;
108
const INVALID_PARAMS: i64 = -32602;
109
1105×
fn error(id: Value, code: i64, message: &str) -> Value {
1115×
    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
1125×
}
113
11415×
fn result(id: Value, result: Value) -> Value {
11515×
    json!({ "jsonrpc": "2.0", "id": id, "result": result })
11615×
}
117
118
/// Answer one message; `None` for a notification (no reply is due).
11922×
pub fn handle(info: &ServerInfo, tools: &dyn Tools, message: &Value) -> Option<Value> {
12022×
    let Some(obj) = message.as_object() else {
1210×
        return Some(error(
1220×
            Value::Null,
1230×
            INVALID_REQUEST,
1240×
            "a message is a JSON object",
1250×
        ));
126
    };
12722×
    let method = obj.get("method").and_then(Value::as_str);
12822×
    let id = obj.get("id").cloned();
12922×
    let Some(method) = method else {
130
        // A response to something we never sent, or nonsense: a request
131
        // without a method gets an error, anything else is ignored.
1320×
        return id.map(|id| error(id, INVALID_REQUEST, "a request needs a method"));
133
    };
134
    // Notifications (initialized, cancelled, …) carry no id and get no reply.
13522×
    let id = id?;
13619×
    let params = obj.get("params").cloned().unwrap_or(Value::Null);
13719×
    Some(match method {
13819×
        "initialize" => {
1393×
            let asked = params.get("protocolVersion").and_then(Value::as_str);
1403×
            let version = asked
1413×
                .filter(|v| PROTOCOL_VERSIONS.contains(v))
1423×
                .unwrap_or(PROTOCOL_VERSIONS[0]);
1433×
            result(
1443×
                id,
1453×
                json!({
1463×
                    "protocolVersion": version,
1473×
                    "capabilities": { "tools": { "listChanged": false } },
1483×
                    "serverInfo": { "name": info.name, "version": info.version },
1493×
                    "instructions": info.instructions,
150
                }),
151
            )
152
        }
15316×
        "ping" => result(id, json!({})),
15415×
        "tools/list" => result(
1553×
            id,
1563×
            json!({ "tools": tools.list().iter().map(Tool::describe).collect::<Vec<_>>() }),
157
        ),
15812×
        "tools/call" => {
15911×
            let Some(name) = params.get("name").and_then(Value::as_str) else {
1600×
                return Some(error(
1610×
                    id,
1620×
                    INVALID_PARAMS,
1630×
                    "tools/call needs the tool's name",
1640×
                ));
165
            };
16611×
            let args = params
16711×
                .get("arguments")
16811×
                .cloned()
16911×
                .unwrap_or_else(|| json!({}));
17011×
            if !args.is_object() {
1711×
                return Some(error(
1721×
                    id,
1731×
                    INVALID_PARAMS,
1741×
                    "a tool's arguments are a JSON object",
1751×
                ));
17610×
            }
17710×
            match tools.call(name, &args) {
1788×
                Some(out) => result(id, out.to_json()),
1792×
                None => error(id, INVALID_PARAMS, &format!("no tool named {name}")),
180
            }
181
        }
1821×
        other => error(id, METHOD_NOT_FOUND, &format!("{other} is not supported")),
183
    })
18422×
}
185
186
/// Serve until the input ends: read a message per line, write a reply per
187
/// request. A line that is not JSON gets a parse error; blank lines are
188
/// skipped.
1894×
pub fn serve(
1904×
    info: &ServerInfo,
1914×
    tools: &dyn Tools,
1924×
    input: impl BufRead,
1934×
    mut output: impl Write,
1944×
) -> std::io::Result<()> {
19523×
    for line in input.lines() {
19623×
        let line = line?;
19723×
        if line.trim().is_empty() {
1981×
            continue;
19922×
        }
20022×
        let reply = match serde_json::from_str::<Value>(&line) {
2011×
            Ok(Value::Array(batch)) => {
2021×
                let replies: Vec<Value> = batch
2031×
                    .iter()
2042×
                    .filter_map(|m| handle(info, tools, m))
2051×
                    .collect();
2061×
                (!replies.is_empty()).then_some(Value::Array(replies))
207
            }
20820×
            Ok(message) => handle(info, tools, &message),
2091×
            Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("not JSON: {e}"))),
210
        };
21122×
        if let Some(reply) = reply {
21220×
            writeln!(output, "{reply}")?;
21320×
            output.flush()?;
2142×
        }
215
    }
2164×
    Ok(())
2174×
}
218
219
/// A string argument, if given and a string.
22020×
pub fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
22120×
    args.get(key).and_then(Value::as_str)
22220×
}
223
224
/// A list of strings, if given; a single string counts as a list of one.
22510×
pub fn arg_strings(args: &Value, key: &str) -> Result<Vec<String>, String> {
22610×
    match args.get(key) {
2274×
        None | Some(Value::Null) => Ok(vec![]),
2281×
        Some(Value::String(s)) => Ok(vec![s.clone()]),
2295×
        Some(Value::Array(a)) => a
2305×
            .iter()
2316×
            .map(|v| {
2326×
                v.as_str()
2336×
                    .map(str::to_string)
2346×
                    .ok_or_else(|| format!("{key} must be a list of strings"))
2356×
            })
2365×
            .collect(),
2370×
        Some(_) => Err(format!("{key} must be a list of strings")),
238
    }
23910×
}
240
241
#[cfg(test)]
242
mod tests {
243
    use super::*;
244
245
    struct Echo;
246
247
    impl Tools for Echo {
2481×
        fn list(&self) -> Vec<Tool> {
2491×
            vec![Tool {
2501×
                name: "echo".into(),
2511×
                description: "says it back".into(),
2521×
                input_schema: json!({"type": "object"}),
2531×
                read_only: true,
2541×
                destructive: false,
2551×
            }]
2561×
        }
2572×
        fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> {
2582×
            (name == "echo").then(|| ToolOutput {
2591×
                text: args.to_string(),
2601×
                structured: Some(json!([1, 2])),
261
                is_error: false,
2621×
            })
2632×
        }
264
    }
265
2662×
    fn info() -> ServerInfo {
2672×
        ServerInfo {
2682×
            name: "t".into(),
2692×
            version: "1".into(),
2702×
            instructions: "be careful".into(),
2712×
        }
2722×
    }
273
2742×
    fn talk(lines: &str) -> Vec<Value> {
2752×
        let mut out = Vec::new();
2762×
        serve(&info(), &Echo, lines.as_bytes(), &mut out).unwrap();
2772×
        String::from_utf8(out)
2782×
            .unwrap()
2792×
            .lines()
2809×
            .map(|l| serde_json::from_str(l).unwrap())
2812×
            .collect()
2822×
    }
283
284
    #[test]
2851×
    fn a_session_initializes_lists_and_calls() {
2861×
        let r = talk(concat!(
287
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}"#,
288
            "\n",
289
            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
290
            "\n\n",
291
            r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
292
            "\n",
293
            r#"{"jsonrpc":"2.0","id":"three","method":"tools/call","params":{"name":"echo","arguments":{"a":1}}}"#,
294
            "\n",
295
        ));
2961×
        assert_eq!(r.len(), 3, "the notification gets no reply: {r:?}");
2971×
        assert_eq!(
2981×
            r[0]["result"]["protocolVersion"], "2025-03-26",
299
            "a version we speak is kept"
300
        );
3011×
        assert_eq!(r[0]["result"]["instructions"], "be careful");
3021×
        assert_eq!(r[1]["result"]["tools"][0]["name"], "echo");
3031×
        assert_eq!(
3041×
            r[1]["result"]["tools"][0]["annotations"]["readOnlyHint"],
305
            true
306
        );
3071×
        assert_eq!(r[2]["id"], "three");
3081×
        assert_eq!(r[2]["result"]["content"][0]["text"], r#"{"a":1}"#);
3091×
        assert_eq!(
3101×
            r[2]["result"]["structuredContent"],
3111×
            json!({"result": [1, 2]}),
312
            "an array is wrapped in an object"
313
        );
3141×
        assert_eq!(r[2]["result"]["isError"], false);
3151×
    }
316
317
    #[test]
3181×
    fn errors_are_protocol_errors_and_unknown_versions_get_the_newest() {
3191×
        let r = talk(concat!(
320
            "not json\n",
321
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}"#,
322
            "\n",
323
            r#"{"jsonrpc":"2.0","id":2,"method":"resources/list"}"#,
324
            "\n",
325
            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nope"}}"#,
326
            "\n",
327
            r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":[1]}}"#,
328
            "\n",
329
            r#"[{"jsonrpc":"2.0","id":5,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled"}]"#,
330
            "\n",
331
        ));
3321×
        assert_eq!(r[0]["error"]["code"], PARSE_ERROR);
3331×
        assert_eq!(r[1]["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
3341×
        assert_eq!(r[2]["error"]["code"], METHOD_NOT_FOUND);
3351×
        assert_eq!(r[3]["error"]["code"], INVALID_PARAMS);
3361×
        assert_eq!(r[4]["error"]["code"], INVALID_PARAMS);
3371×
        assert_eq!(
3381×
            r[5],
3391×
            json!([{"jsonrpc": "2.0", "id": 5, "result": {}}]),
340
            "a batch is answered as a batch"
341
        );
3421×
    }
343
344
    #[test]
3451×
    fn string_arguments_and_lists() {
3461×
        let a = json!({"one": "x", "many": ["a", "b"], "bad": [1]});
3471×
        assert_eq!(arg_str(&a, "one"), Some("x"));
3481×
        assert_eq!(arg_strings(&a, "one").unwrap(), vec!["x"]);
3491×
        assert_eq!(arg_strings(&a, "many").unwrap(), vec!["a", "b"]);
3501×
        assert_eq!(arg_strings(&a, "missing").unwrap(), Vec::<String>::new());
3511×
        assert!(arg_strings(&a, "bad").is_err());
3521×
    }
353
}