Skip to main content

subako_function_sdk/
stdio.rs

1//! The MCP messages a guest reads and writes over stdio, a JSON-RPC 2.0
2//! message per line.
3
4use serde::{Deserialize, Serialize};
5
6/// The MCP revision a guest speaks.
7// CODESYNC(mcp-protocol-version)
8pub(crate) const MCP_PROTOCOL_VERSION: &str = "2026-07-28";
9
10// CODESYNC(mcp-wire)
11const JSONRPC_VERSION: &str = "2.0";
12const DISCOVER: &str = "server/discover";
13const TOOLS_LIST: &str = "tools/list";
14const TOOLS_CALL: &str = "tools/call";
15const RESULT_COMPLETE: &str = "complete";
16const PROTOCOL_VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
17const CLIENT_CAPABILITIES_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
18const SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
19
20const PARSE_ERROR: i64 = -32700;
21const INVALID_REQUEST: i64 = -32600;
22const METHOD_NOT_FOUND: i64 = -32601;
23const INVALID_PARAMS: i64 = -32602;
24const INTERNAL_ERROR: i64 = -32603;
25const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
26
27/// How long a client may keep a `tools/list` result: a module's tools never
28/// change.
29const TOOLS_TTL_MS: u64 = 24 * 60 * 60 * 1000;
30
31/// A request's id, which its response echoes.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(untagged)]
34pub(crate) enum RequestId {
35    Number(i64),
36    String(String),
37}
38
39/// What one line of stdin asks of the guest.
40pub(crate) enum Incoming {
41    Discover {
42        id: RequestId,
43    },
44    ListTools {
45        id: RequestId,
46    },
47    Call(ToolsCall),
48    /// A notification or a response, which the guest reads past.
49    Ignored,
50    /// A line the guest answers with a JSON-RPC error.
51    Refused(Refusal),
52}
53
54/// A `tools/call` request.
55pub(crate) struct ToolsCall {
56    pub(crate) id: RequestId,
57    pub(crate) name: String,
58    pub(crate) arguments: serde_json::Value,
59}
60
61/// A message as JSON-RPC lays it out, before its members say which kind it
62/// is.
63#[derive(Deserialize)]
64struct RawMessage {
65    jsonrpc: String,
66    #[serde(default)]
67    id: Option<RequestId>,
68    #[serde(default)]
69    method: Option<String>,
70    #[serde(default)]
71    params: Option<serde_json::Value>,
72    #[serde(default)]
73    result: Option<serde_json::Value>,
74    #[serde(default)]
75    error: Option<serde_json::Value>,
76}
77
78#[derive(Deserialize)]
79struct ToolsCallParams {
80    name: String,
81    /// MCP lets a call leave its arguments out.
82    #[serde(default = "no_arguments")]
83    arguments: serde_json::Value,
84}
85
86fn no_arguments() -> serde_json::Value {
87    serde_json::Value::Object(serde_json::Map::new())
88}
89
90/// Why a line is answered with a JSON-RPC error rather than served.
91pub(crate) struct Refusal {
92    /// Absent where no id could be read.
93    id: Option<RequestId>,
94    code: i64,
95    message: String,
96    data: Option<Box<serde_json::Value>>,
97}
98
99impl Refusal {
100    fn new(id: Option<RequestId>, code: i64, message: String) -> Refusal {
101        Refusal {
102            id,
103            code,
104            message,
105            data: None,
106        }
107    }
108
109    pub(crate) fn unreadable(error: &std::io::Error) -> Refusal {
110        Refusal::new(None, INTERNAL_ERROR, format!("stdin unreadable: {error}"))
111    }
112
113    pub(crate) fn line(&self) -> Vec<u8> {
114        let error = serde_json::json!({"code": self.code, "message": self.message});
115        let error = match &self.data {
116            None => error,
117            Some(data) => {
118                serde_json::json!({"code": self.code, "message": self.message, "data": data})
119            }
120        };
121        line(&serde_json::json!({"jsonrpc": JSONRPC_VERSION, "id": self.id, "error": error}))
122    }
123}
124
125/// Reads the message on `line`, which may end in its newline.
126pub(crate) fn read(line: &[u8]) -> Incoming {
127    let refused = |id, code, message| Incoming::Refused(Refusal::new(id, code, message));
128    let value: serde_json::Value = match serde_json::from_slice(line) {
129        Ok(value) => value,
130        Err(e) => return refused(None, PARSE_ERROR, format!("not JSON: {e}")),
131    };
132    let message: RawMessage = match serde_json::from_value(value) {
133        Ok(message) => message,
134        Err(e) => return refused(None, INVALID_REQUEST, format!("not a message: {e}")),
135    };
136    match message.jsonrpc.as_str() {
137        JSONRPC_VERSION => {}
138        version => {
139            return refused(
140                message.id,
141                INVALID_REQUEST,
142                format!("unknown JSON-RPC version: {version}"),
143            );
144        }
145    }
146    let (id, method) = match (message.id, message.method) {
147        (Some(id), Some(method)) => (id, method),
148        (None, Some(_)) => return Incoming::Ignored,
149        (Some(_), None) if message.result.is_some() || message.error.is_some() => {
150            return Incoming::Ignored;
151        }
152        (id, None) => {
153            return refused(
154                id,
155                INVALID_REQUEST,
156                "neither a request, a notification, nor a response".to_string(),
157            );
158        }
159    };
160    let params = message.params.unwrap_or(serde_json::Value::Null);
161    if let Err(refusal) = check_meta(&id, &params) {
162        return Incoming::Refused(refusal);
163    }
164    match method.as_str() {
165        DISCOVER => Incoming::Discover { id },
166        TOOLS_LIST => match params.get("cursor") {
167            // Every tool is on the first page, so no cursor names another.
168            Some(_) => refused(Some(id), INVALID_PARAMS, "no such cursor".to_string()),
169            None => Incoming::ListTools { id },
170        },
171        TOOLS_CALL => match serde_json::from_value::<ToolsCallParams>(params) {
172            Ok(params) => Incoming::Call(ToolsCall {
173                id,
174                name: params.name,
175                arguments: params.arguments,
176            }),
177            Err(e) => refused(
178                Some(id),
179                INVALID_PARAMS,
180                format!("invalid tools/call params: {e}"),
181            ),
182        },
183        other => refused(
184            Some(id),
185            METHOD_NOT_FOUND,
186            format!("unknown method: {other}"),
187        ),
188    }
189}
190
191/// Checks the `_meta` every request carries: the revision it speaks, which
192/// must be this guest's, and the client's capabilities.
193fn check_meta(id: &RequestId, params: &serde_json::Value) -> Result<(), Refusal> {
194    let meta = params.get("_meta");
195    let field = |key| meta.and_then(|meta| meta.get(key));
196    let missing = |key| {
197        Refusal::new(
198            Some(id.clone()),
199            INVALID_PARAMS,
200            format!("missing _meta {key}; this server speaks MCP {MCP_PROTOCOL_VERSION}"),
201        )
202    };
203    let version = match field(PROTOCOL_VERSION_KEY) {
204        Some(serde_json::Value::String(version)) => version,
205        Some(_) | None => return Err(missing(PROTOCOL_VERSION_KEY)),
206    };
207    match version.as_str() {
208        MCP_PROTOCOL_VERSION => {}
209        other => {
210            return Err(Refusal {
211                id: Some(id.clone()),
212                code: UNSUPPORTED_PROTOCOL_VERSION,
213                message: "Unsupported protocol version".to_string(),
214                data: Some(Box::new(serde_json::json!({
215                    "supported": [MCP_PROTOCOL_VERSION],
216                    "requested": other,
217                }))),
218            });
219        }
220    }
221    match field(CLIENT_CAPABILITIES_KEY) {
222        Some(serde_json::Value::Object(_)) => Ok(()),
223        Some(_) | None => Err(missing(CLIENT_CAPABILITIES_KEY)),
224    }
225}
226
227/// Who answers: MCP's `serverInfo`.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct ServerInfo<'a> {
230    pub name: &'a str,
231    pub version: &'a str,
232}
233
234/// The answer to `server/discover`: the revision this guest speaks, and that
235/// it serves tools.
236pub(crate) fn discover_line(id: &RequestId, server: &ServerInfo<'_>) -> Vec<u8> {
237    result_line(
238        id,
239        server,
240        [
241            (
242                "supportedVersions",
243                serde_json::json!([MCP_PROTOCOL_VERSION]),
244            ),
245            ("capabilities", serde_json::json!({"tools": {}})),
246        ]
247        .into_iter(),
248    )
249}
250
251pub(crate) fn tools_line(
252    id: &RequestId,
253    server: &ServerInfo<'_>,
254    tools: Vec<serde_json::Value>,
255) -> Vec<u8> {
256    result_line(
257        id,
258        server,
259        [
260            ("tools", serde_json::json!(tools)),
261            ("ttlMs", serde_json::json!(TOOLS_TTL_MS)),
262            ("cacheScope", serde_json::json!("public")),
263        ]
264        .into_iter(),
265    )
266}
267
268/// The response carrying a result of `fields`, marked complete and signed
269/// with `server`.
270pub(crate) fn result_line<'k>(
271    id: &RequestId,
272    server: &ServerInfo<'_>,
273    fields: impl Iterator<Item = (&'k str, serde_json::Value)>,
274) -> Vec<u8> {
275    let signed = [
276        ("resultType", serde_json::json!(RESULT_COMPLETE)),
277        (
278            "_meta",
279            serde_json::json!({
280                SERVER_INFO_KEY: {"name": server.name, "version": server.version},
281            }),
282        ),
283    ];
284    let result: serde_json::Map<String, serde_json::Value> = fields
285        .chain(signed)
286        .map(|(key, value)| (key.to_string(), value))
287        .collect();
288    line(&serde_json::json!({"jsonrpc": JSONRPC_VERSION, "id": id, "result": result}))
289}
290
291/// `message` as one line: compact JSON escapes every newline inside it.
292fn line(message: &serde_json::Value) -> Vec<u8> {
293    // A `Value` always serializes.
294    let mut line = serde_json::to_vec(message).unwrap_or_default();
295    line.push(b'\n');
296    line
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn an_unreadable_stdin_is_an_internal_error_answering_no_id() {
305        let line = Refusal::unreadable(&std::io::Error::other("gone")).line();
306        let message: serde_json::Value = serde_json::from_slice(&line).unwrap();
307        assert_eq!(
308            message,
309            serde_json::json!({
310                "jsonrpc": "2.0",
311                "id": null,
312                "error": {"code": INTERNAL_ERROR, "message": "stdin unreadable: gone"},
313            })
314        );
315    }
316}