Skip to main content

tix/mcp/
protocol.rs

1//! Newline-delimited JSON-RPC 2.0 wire types, framing and the frozen MCP
2//! error surface for the `tix mcp` server (D-01 of research.md;
3//! `contracts/mcp-protocol.md`).
4//!
5//! This module owns the transport contract enforced in T001–T004: exactly one
6//! JSON line per response (`C-001`), `stdout` carrying only protocol messages
7//! (`C-005` — the single locked writer is the enforcement point), the
8//! `initialize` handshake with version negotiation (T003), and the frozen
9//! five-error matrix (T004). Tool logic stays out of this module; the
10//! dispatch that maps methods to these primitives lives in [`super::handle`].
11
12use std::io::{self, BufRead, Write};
13
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16
17/// `-32700` — undecodable JSON / bad framing line (`contracts/mcp-protocol.md`
18/// §6).
19pub const PARSE_ERROR: i64 = -32700;
20/// `-32600` — valid JSON but not a JSON-RPC message/method shape.
21pub const INVALID_REQUEST: i64 = -32600;
22/// `-32601` — unknown method.
23pub const METHOD_NOT_FOUND: i64 = -32601;
24/// `-32602` — `tools/call` with an unknown tool name or type-invalid args.
25pub const INVALID_PARAMS: i64 = -32602;
26/// `-32603` — unexpected internal failure (panic guard, classified at the
27/// boundary).
28pub const INTERNAL_ERROR: i64 = -32603;
29
30/// Supported MCP protocol versions (`contracts/mcp-protocol.md` §2), newest
31/// first. [`LATEST_VERSION`] is the negotiation fallback (D-03).
32pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
33
34/// The newest supported protocol version, used when the client's requested
35/// version is not in [`SUPPORTED_PROTOCOL_VERSIONS`].
36pub const LATEST_PROTOCOL_VERSION: &str = "2025-06-18";
37
38/// A JSON-RPC 2.0 `id`. `null` is a *valid* id and must be echoed like any
39/// other id (the MCP contract requires distinguishing `Some(null)` from an
40/// absent `id`).
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(untagged)]
43pub enum Id {
44    Number(i64),
45    String(String),
46    Null,
47}
48
49/// A parsed inbound message: a request (`id` present ⇒ the server MUST
50/// respond) or a notification (`id` absent ⇒ the server MUST NOT respond).
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Message {
53    Request {
54        method: String,
55        params: Option<Value>,
56        id: Id,
57    },
58    Notification {
59        method: String,
60        params: Option<Value>,
61    },
62}
63
64/// What went wrong while decoding a stdin line. The response kind carried by
65/// each variant pins the error code and (where recoverable) the id.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ParseError {
68    /// Not valid JSON — respond `-32700` with `id: null`.
69    MalformedJson,
70    /// Valid JSON but not a well-formed JSON-RPC message. Respond `-32600`
71    /// with the id when one was present; otherwise (notification-shaped) never
72    /// respond.
73    InvalidRequest(Option<Id>),
74}
75
76/// A successful response: `result` carries the method-specific payload.
77#[derive(Debug, Clone, Serialize)]
78pub struct Response {
79    pub jsonrpc: &'static str,
80    pub id: Id,
81    pub result: Value,
82}
83
84/// A JSON-RPC error object (`data` is optional and skipped when absent).
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct RpcError {
87    pub code: i64,
88    pub message: String,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub data: Option<Value>,
91}
92
93/// A failure response using one of the five frozen codes.
94#[derive(Debug, Clone, Serialize)]
95pub struct ErrorResponse {
96    pub jsonrpc: &'static str,
97    pub id: Id,
98    pub error: RpcError,
99}
100
101/// Construct a success response.
102pub fn success(id: Id, result: Value) -> Response {
103    Response {
104        jsonrpc: "2.0",
105        id,
106        result,
107    }
108}
109
110/// `-32700 Parse error` — carried `id` is always `null` (the line did not
111/// parse, so no id was available).
112pub fn parse_error() -> ErrorResponse {
113    ErrorResponse {
114        jsonrpc: "2.0",
115        id: Id::Null,
116        error: RpcError {
117            code: PARSE_ERROR,
118            message: "Parse error".into(),
119            data: None,
120        },
121    }
122}
123
124/// `-32600 Invalid request` for a valid-JSON but malformed message.
125pub fn invalid_request(id: Id) -> ErrorResponse {
126    ErrorResponse {
127        jsonrpc: "2.0",
128        id,
129        error: RpcError {
130            code: INVALID_REQUEST,
131            message: "Invalid Request".into(),
132            data: None,
133        },
134    }
135}
136
137/// `-32601 Method not found`.
138pub fn method_not_found(id: Id) -> ErrorResponse {
139    ErrorResponse {
140        jsonrpc: "2.0",
141        id,
142        error: RpcError {
143            code: METHOD_NOT_FOUND,
144            message: "Method not found".into(),
145            data: None,
146        },
147    }
148}
149
150/// `-32602 Invalid params`.
151pub fn invalid_params(id: Id) -> ErrorResponse {
152    ErrorResponse {
153        jsonrpc: "2.0",
154        id,
155        error: RpcError {
156            code: INVALID_PARAMS,
157            message: "Invalid params".into(),
158            data: None,
159        },
160    }
161}
162
163/// `-32603 Internal error`.
164pub fn internal_error(id: Id) -> ErrorResponse {
165    ErrorResponse {
166        jsonrpc: "2.0",
167        id,
168        error: RpcError {
169            code: INTERNAL_ERROR,
170            message: "Internal error".into(),
171            data: None,
172        },
173    }
174}
175
176/// Echo the client's `protocolVersion` when it is supported; otherwise
177/// negotiate down to [`LATEST_PROTOCOL_VERSION`] (`contracts/mcp-protocol.md`
178/// §2, D-03).
179pub fn negotiate_protocol_version(client_version: &str) -> &'static str {
180    SUPPORTED_PROTOCOL_VERSIONS
181        .iter()
182        .find(|&&v| v == client_version)
183        .copied()
184        .unwrap_or(LATEST_PROTOCOL_VERSION)
185}
186
187/// The `initialize` result payload (`contracts/mcp-protocol.md` §3).
188pub fn initialize_result(protocol_version: &str, server_version: &str) -> Value {
189    json!({
190        "protocolVersion": protocol_version,
191        "capabilities": { "tools": { "listChanged": false } },
192        "serverInfo": { "name": "tix", "version": server_version },
193        "instructions": "tix issue tracker — see each tool's description/inputSchema"
194    })
195}
196
197/// Read one UTF-8 line from `reader`, returning `None` on EOF (clean
198/// shutdown → exit 0, no response). Trailing `\n`/`\r` is stripped so the
199/// framing uses exactly one JSON message per line (C-001).
200pub fn read_line<R: BufRead>(reader: &mut R) -> io::Result<Option<String>> {
201    let mut line = String::new();
202    if reader.read_line(&mut line)? == 0 {
203        return Ok(None);
204    }
205    while line.ends_with('\n') || line.ends_with('\r') {
206        line.pop();
207    }
208    Ok(Some(line))
209}
210
211/// Write one JSON-RPC message as a single newline-terminated line, then
212/// flush. Callers pass the single locked stdout handle owned by
213/// [`super::serve`]; nothing else in the crate may write to stdout during a
214/// serve, so this helper is the stdout-purity enforcement point (C-005).
215pub fn write_message<W: Write, T: Serialize>(writer: &mut W, message: &T) -> io::Result<()> {
216    let line = serde_json::to_string(message).map_err(io::Error::other)?;
217    writer.write_all(line.as_bytes())?;
218    writer.write_all(b"\n")?;
219    writer.flush()
220}
221
222/// Decode a stdin line into a [`Message`]. A line that is not valid JSON is a
223/// [`ParseError::MalformedJson`]; valid JSON that is not a well-formed JSON-RPC
224/// message is [`ParseError::InvalidRequest`] carrying the id (if extractable).
225pub fn parse_message(line: &str) -> Result<Message, ParseError> {
226    let value: Value = serde_json::from_str(line).map_err(|_| ParseError::MalformedJson)?;
227    if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
228        return Err(ParseError::InvalidRequest(extract_id(&value)));
229    }
230    let method = value
231        .get("method")
232        .and_then(Value::as_str)
233        .ok_or(ParseError::InvalidRequest(extract_id(&value)))?;
234    let params = value.get("params").cloned();
235    match extract_id(&value) {
236        Some(id) => Ok(Message::Request {
237            method: method.to_string(),
238            params,
239            id,
240        }),
241        None => Ok(Message::Notification {
242            method: method.to_string(),
243            params,
244        }),
245    }
246}
247
248/// Pull the `id` field (presence is meaningful: `Some(Id::Null)` is a valid
249/// request id; `None` means "notification"). An id of an unusable type is
250/// treated as absent so the value still routes as a notification/no-response.
251fn extract_id(value: &Value) -> Option<Id> {
252    let raw = value.get("id")?;
253    if raw.is_object() || raw.is_array() || raw.is_boolean() {
254        return None;
255    }
256    Id::deserialize(raw).ok()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn request_with_numeric_id_is_a_request() {
265        let msg = parse_message(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap();
266        assert_eq!(
267            msg,
268            Message::Request {
269                method: "ping".into(),
270                params: None,
271                id: Id::Number(1),
272            }
273        );
274    }
275
276    #[test]
277    fn request_with_string_id_is_a_request() {
278        let msg = parse_message(r#"{"jsonrpc":"2.0","id":"a","method":"ping"}"#).unwrap();
279        assert!(matches!(
280            msg,
281            Message::Request { id: Id::String(s), .. } if s == "a"
282        ));
283    }
284
285    #[test]
286    fn explicit_null_id_is_a_request_not_a_notification() {
287        // `Some(null)` is a valid JSON-RPC id and must demand a response;
288        // indexing into `Option<Id>` would collapse it to an absent id.
289        let msg = parse_message(r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#).unwrap();
290        assert!(matches!(msg, Message::Request { id: Id::Null, .. }));
291    }
292
293    #[test]
294    fn absent_id_is_a_notification() {
295        let msg =
296            parse_message(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).unwrap();
297        assert!(
298            matches!(msg, Message::Notification { method, .. } if method == "notifications/initialized")
299        );
300    }
301
302    #[test]
303    fn malformed_json_is_parse_error() {
304        assert_eq!(
305            parse_message("{\"jsonrpc\":\"2.0\", not json"),
306            Err(ParseError::MalformedJson)
307        );
308    }
309
310    #[test]
311    fn non_22_jsonrpc_version_is_invalid_request() {
312        assert_eq!(
313            parse_message(r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#),
314            Err(ParseError::InvalidRequest(Some(Id::Number(1))))
315        );
316    }
317
318    #[test]
319    fn missing_method_is_invalid_request_with_id() {
320        assert_eq!(
321            parse_message(r#"{"jsonrpc":"2.0","id":7}"#),
322            Err(ParseError::InvalidRequest(Some(Id::Number(7))))
323        );
324    }
325
326    #[test]
327    fn invalid_id_type_is_treated_as_absent_id() {
328        assert!(matches!(
329            parse_message(r#"{"jsonrpc":"2.0","id":true,"method":"ping"}"#),
330            Ok(Message::Notification { method, .. }) if method == "ping"
331        ));
332    }
333
334    #[test]
335    fn request_carries_params_value() {
336        let msg = parse_message(
337            r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
338        )
339        .unwrap();
340        assert!(matches!(msg, Message::Request { method, params, id } if
341            method == "initialize"
342            && params == Some(json!({"protocolVersion": "2025-06-18"}))
343            && id == Id::Number(2)));
344    }
345
346    #[test]
347    fn version_negotiation_echoes_supported_versions() {
348        assert_eq!(negotiate_protocol_version("2025-06-18"), "2025-06-18");
349        assert_eq!(negotiate_protocol_version("2025-03-26"), "2025-03-26");
350        assert_eq!(negotiate_protocol_version("2024-11-05"), "2024-11-05");
351    }
352
353    #[test]
354    fn version_negotiation_falls_back_to_latest() {
355        assert_eq!(negotiate_protocol_version("2024-01-01"), "2025-06-18");
356        assert_eq!(negotiate_protocol_version(""), "2025-06-18");
357    }
358
359    #[test]
360    fn error_constructors_have_frozen_codes() {
361        let id = Id::Number(3);
362        assert_eq!(parse_error().id, Id::Null);
363        assert_eq!(parse_error().error.code, PARSE_ERROR);
364        assert_eq!(invalid_request(id.clone()).error.code, INVALID_REQUEST);
365        assert_eq!(method_not_found(id.clone()).error.code, METHOD_NOT_FOUND);
366        assert_eq!(invalid_params(id.clone()).error.code, INVALID_PARAMS);
367        assert_eq!(internal_error(id.clone()).error.code, INTERNAL_ERROR);
368        assert_eq!(invalid_request(id).error.message, "Invalid Request");
369    }
370
371    #[test]
372    fn success_response_serializes_as_single_2d0_message() {
373        let resp = success(Id::Number(1), json!({"ok": true}));
374        let mut out: Vec<u8> = Vec::new();
375        write_message(&mut out, &resp).unwrap();
376        let s = String::from_utf8(out).unwrap();
377        assert_eq!(
378            s,
379            "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n"
380        );
381    }
382
383    #[test]
384    fn error_response_omits_data_when_absent() {
385        let resp = method_not_found(Id::Null);
386        let mut out: Vec<u8> = Vec::new();
387        write_message(&mut out, &resp).unwrap();
388        let s = String::from_utf8(out).unwrap();
389        assert_eq!(
390            s,
391            "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}\n"
392        );
393    }
394
395    #[test]
396    fn write_message_never_leaks_bytes_after_the_line() {
397        // Byte-level purity: one message, one trailing newline, nothing else.
398        let resp = success(Id::String("x".into()), json!({}));
399        let mut out: Vec<u8> = Vec::new();
400        write_message(&mut out, &resp).unwrap();
401        assert_eq!(out.last(), Some(&b'\n'));
402        let s = String::from_utf8(out).unwrap();
403        assert_eq!(s.matches('\n').count(), 1);
404        assert!(s.ends_with('\n'));
405    }
406
407    #[test]
408    fn read_line_returns_none_on_eof() {
409        let mut reader: &[u8] = b"";
410        assert_eq!(read_line(&mut reader).unwrap(), None);
411    }
412
413    #[test]
414    fn read_line_strips_trailing_newline() {
415        let mut reader: &[u8] = b"{\"a\":1}\n{\"b\":2}\r\n";
416        assert_eq!(read_line(&mut reader).unwrap(), Some("{\"a\":1}".into()));
417        assert_eq!(read_line(&mut reader).unwrap(), Some("{\"b\":2}".into()));
418        assert_eq!(read_line(&mut reader).unwrap(), None);
419    }
420
421    #[test]
422    fn initialize_result_shape_matches_contract() {
423        let result = initialize_result("2025-06-18", "0.1.0");
424        assert_eq!(
425            result,
426            json!({
427                "protocolVersion": "2025-06-18",
428                "capabilities": { "tools": { "listChanged": false } },
429                "serverInfo": { "name": "tix", "version": "0.1.0" },
430                "instructions": "tix issue tracker — see each tool's description/inputSchema"
431            })
432        );
433    }
434}