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    #[allow(clippy::wildcard_imports)]
262    use super::*;
263
264    #[test]
265    fn request_with_numeric_id_is_a_request() {
266        let msg = parse_message(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap();
267        assert_eq!(
268            msg,
269            Message::Request {
270                method: "ping".into(),
271                params: None,
272                id: Id::Number(1),
273            }
274        );
275    }
276
277    #[test]
278    fn request_with_string_id_is_a_request() {
279        let msg = parse_message(r#"{"jsonrpc":"2.0","id":"a","method":"ping"}"#).unwrap();
280        assert!(matches!(
281            msg,
282            Message::Request { id: Id::String(s), .. } if s == "a"
283        ));
284    }
285
286    #[test]
287    fn explicit_null_id_is_a_request_not_a_notification() {
288        // `Some(null)` is a valid JSON-RPC id and must demand a response;
289        // indexing into `Option<Id>` would collapse it to an absent id.
290        let msg = parse_message(r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#).unwrap();
291        assert!(matches!(msg, Message::Request { id: Id::Null, .. }));
292    }
293
294    #[test]
295    fn absent_id_is_a_notification() {
296        let msg =
297            parse_message(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).unwrap();
298        assert!(
299            matches!(msg, Message::Notification { method, .. } if method == "notifications/initialized")
300        );
301    }
302
303    #[test]
304    fn malformed_json_is_parse_error() {
305        assert_eq!(
306            parse_message("{\"jsonrpc\":\"2.0\", not json"),
307            Err(ParseError::MalformedJson)
308        );
309    }
310
311    #[test]
312    fn non_22_jsonrpc_version_is_invalid_request() {
313        assert_eq!(
314            parse_message(r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#),
315            Err(ParseError::InvalidRequest(Some(Id::Number(1))))
316        );
317    }
318
319    #[test]
320    fn missing_method_is_invalid_request_with_id() {
321        assert_eq!(
322            parse_message(r#"{"jsonrpc":"2.0","id":7}"#),
323            Err(ParseError::InvalidRequest(Some(Id::Number(7))))
324        );
325    }
326
327    #[test]
328    fn invalid_id_type_is_treated_as_absent_id() {
329        assert!(matches!(
330            parse_message(r#"{"jsonrpc":"2.0","id":true,"method":"ping"}"#),
331            Ok(Message::Notification { method, .. }) if method == "ping"
332        ));
333    }
334
335    #[test]
336    fn request_carries_params_value() {
337        let msg = parse_message(
338            r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
339        )
340        .unwrap();
341        assert!(matches!(msg, Message::Request { method, params, id } if
342            method == "initialize"
343            && params == Some(json!({"protocolVersion": "2025-06-18"}))
344            && id == Id::Number(2)));
345    }
346
347    #[test]
348    fn version_negotiation_echoes_supported_versions() {
349        assert_eq!(negotiate_protocol_version("2025-06-18"), "2025-06-18");
350        assert_eq!(negotiate_protocol_version("2025-03-26"), "2025-03-26");
351        assert_eq!(negotiate_protocol_version("2024-11-05"), "2024-11-05");
352    }
353
354    #[test]
355    fn version_negotiation_falls_back_to_latest() {
356        assert_eq!(negotiate_protocol_version("2024-01-01"), "2025-06-18");
357        assert_eq!(negotiate_protocol_version(""), "2025-06-18");
358    }
359
360    #[test]
361    fn error_constructors_have_frozen_codes() {
362        let id = Id::Number(3);
363        assert_eq!(parse_error().id, Id::Null);
364        assert_eq!(parse_error().error.code, PARSE_ERROR);
365        assert_eq!(invalid_request(id.clone()).error.code, INVALID_REQUEST);
366        assert_eq!(method_not_found(id.clone()).error.code, METHOD_NOT_FOUND);
367        assert_eq!(invalid_params(id.clone()).error.code, INVALID_PARAMS);
368        assert_eq!(internal_error(id.clone()).error.code, INTERNAL_ERROR);
369        assert_eq!(invalid_request(id).error.message, "Invalid Request");
370    }
371
372    #[test]
373    fn success_response_serializes_as_single_2d0_message() {
374        let resp = success(Id::Number(1), json!({"ok": true}));
375        let mut out: Vec<u8> = Vec::new();
376        write_message(&mut out, &resp).unwrap();
377        let s = String::from_utf8(out).unwrap();
378        assert_eq!(
379            s,
380            "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n"
381        );
382    }
383
384    #[test]
385    fn error_response_omits_data_when_absent() {
386        let resp = method_not_found(Id::Null);
387        let mut out: Vec<u8> = Vec::new();
388        write_message(&mut out, &resp).unwrap();
389        let s = String::from_utf8(out).unwrap();
390        assert_eq!(
391            s,
392            "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}\n"
393        );
394    }
395
396    #[test]
397    fn write_message_never_leaks_bytes_after_the_line() {
398        // Byte-level purity: one message, one trailing newline, nothing else.
399        let resp = success(Id::String("x".into()), json!({}));
400        let mut out: Vec<u8> = Vec::new();
401        write_message(&mut out, &resp).unwrap();
402        assert_eq!(out.last(), Some(&b'\n'));
403        let s = String::from_utf8(out).unwrap();
404        assert_eq!(s.matches('\n').count(), 1);
405        assert!(s.ends_with('\n'));
406    }
407
408    #[test]
409    fn read_line_returns_none_on_eof() {
410        let mut reader: &[u8] = b"";
411        assert_eq!(read_line(&mut reader).unwrap(), None);
412    }
413
414    #[test]
415    fn read_line_strips_trailing_newline() {
416        let mut reader: &[u8] = b"{\"a\":1}\n{\"b\":2}\r\n";
417        assert_eq!(read_line(&mut reader).unwrap(), Some("{\"a\":1}".into()));
418        assert_eq!(read_line(&mut reader).unwrap(), Some("{\"b\":2}".into()));
419        assert_eq!(read_line(&mut reader).unwrap(), None);
420    }
421
422    #[test]
423    fn initialize_result_shape_matches_contract() {
424        let result = initialize_result("2025-06-18", "0.1.0");
425        assert_eq!(
426            result,
427            json!({
428                "protocolVersion": "2025-06-18",
429                "capabilities": { "tools": { "listChanged": false } },
430                "serverInfo": { "name": "tix", "version": "0.1.0" },
431                "instructions": "tix issue tracker — see each tool's description/inputSchema"
432            })
433        );
434    }
435}