tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Newline-delimited JSON-RPC 2.0 wire types, framing and the frozen MCP
//! error surface for the `tix mcp` server (D-01 of research.md;
//! `contracts/mcp-protocol.md`).
//!
//! This module owns the transport contract enforced in T001–T004: exactly one
//! JSON line per response (`C-001`), `stdout` carrying only protocol messages
//! (`C-005` — the single locked writer is the enforcement point), the
//! `initialize` handshake with version negotiation (T003), and the frozen
//! five-error matrix (T004). Tool logic stays out of this module; the
//! dispatch that maps methods to these primitives lives in [`super::handle`].

use std::io::{self, BufRead, Write};

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// `-32700` — undecodable JSON / bad framing line (`contracts/mcp-protocol.md`
/// §6).
pub const PARSE_ERROR: i64 = -32700;
/// `-32600` — valid JSON but not a JSON-RPC message/method shape.
pub const INVALID_REQUEST: i64 = -32600;
/// `-32601` — unknown method.
pub const METHOD_NOT_FOUND: i64 = -32601;
/// `-32602` — `tools/call` with an unknown tool name or type-invalid args.
pub const INVALID_PARAMS: i64 = -32602;
/// `-32603` — unexpected internal failure (panic guard, classified at the
/// boundary).
pub const INTERNAL_ERROR: i64 = -32603;

/// Supported MCP protocol versions (`contracts/mcp-protocol.md` §2), newest
/// first. [`LATEST_VERSION`] is the negotiation fallback (D-03).
pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];

/// The newest supported protocol version, used when the client's requested
/// version is not in [`SUPPORTED_PROTOCOL_VERSIONS`].
pub const LATEST_PROTOCOL_VERSION: &str = "2025-06-18";

/// A JSON-RPC 2.0 `id`. `null` is a *valid* id and must be echoed like any
/// other id (the MCP contract requires distinguishing `Some(null)` from an
/// absent `id`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Id {
    Number(i64),
    String(String),
    Null,
}

/// A parsed inbound message: a request (`id` present ⇒ the server MUST
/// respond) or a notification (`id` absent ⇒ the server MUST NOT respond).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
    Request {
        method: String,
        params: Option<Value>,
        id: Id,
    },
    Notification {
        method: String,
        params: Option<Value>,
    },
}

/// What went wrong while decoding a stdin line. The response kind carried by
/// each variant pins the error code and (where recoverable) the id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
    /// Not valid JSON — respond `-32700` with `id: null`.
    MalformedJson,
    /// Valid JSON but not a well-formed JSON-RPC message. Respond `-32600`
    /// with the id when one was present; otherwise (notification-shaped) never
    /// respond.
    InvalidRequest(Option<Id>),
}

/// A successful response: `result` carries the method-specific payload.
#[derive(Debug, Clone, Serialize)]
pub struct Response {
    pub jsonrpc: &'static str,
    pub id: Id,
    pub result: Value,
}

/// A JSON-RPC error object (`data` is optional and skipped when absent).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RpcError {
    pub code: i64,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<Value>,
}

/// A failure response using one of the five frozen codes.
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResponse {
    pub jsonrpc: &'static str,
    pub id: Id,
    pub error: RpcError,
}

/// Construct a success response.
pub fn success(id: Id, result: Value) -> Response {
    Response {
        jsonrpc: "2.0",
        id,
        result,
    }
}

/// `-32700 Parse error` — carried `id` is always `null` (the line did not
/// parse, so no id was available).
pub fn parse_error() -> ErrorResponse {
    ErrorResponse {
        jsonrpc: "2.0",
        id: Id::Null,
        error: RpcError {
            code: PARSE_ERROR,
            message: "Parse error".into(),
            data: None,
        },
    }
}

/// `-32600 Invalid request` for a valid-JSON but malformed message.
pub fn invalid_request(id: Id) -> ErrorResponse {
    ErrorResponse {
        jsonrpc: "2.0",
        id,
        error: RpcError {
            code: INVALID_REQUEST,
            message: "Invalid Request".into(),
            data: None,
        },
    }
}

/// `-32601 Method not found`.
pub fn method_not_found(id: Id) -> ErrorResponse {
    ErrorResponse {
        jsonrpc: "2.0",
        id,
        error: RpcError {
            code: METHOD_NOT_FOUND,
            message: "Method not found".into(),
            data: None,
        },
    }
}

/// `-32602 Invalid params`.
pub fn invalid_params(id: Id) -> ErrorResponse {
    ErrorResponse {
        jsonrpc: "2.0",
        id,
        error: RpcError {
            code: INVALID_PARAMS,
            message: "Invalid params".into(),
            data: None,
        },
    }
}

/// `-32603 Internal error`.
pub fn internal_error(id: Id) -> ErrorResponse {
    ErrorResponse {
        jsonrpc: "2.0",
        id,
        error: RpcError {
            code: INTERNAL_ERROR,
            message: "Internal error".into(),
            data: None,
        },
    }
}

/// Echo the client's `protocolVersion` when it is supported; otherwise
/// negotiate down to [`LATEST_PROTOCOL_VERSION`] (`contracts/mcp-protocol.md`
/// §2, D-03).
pub fn negotiate_protocol_version(client_version: &str) -> &'static str {
    SUPPORTED_PROTOCOL_VERSIONS
        .iter()
        .find(|&&v| v == client_version)
        .copied()
        .unwrap_or(LATEST_PROTOCOL_VERSION)
}

/// The `initialize` result payload (`contracts/mcp-protocol.md` §3).
pub fn initialize_result(protocol_version: &str, server_version: &str) -> Value {
    json!({
        "protocolVersion": protocol_version,
        "capabilities": { "tools": { "listChanged": false } },
        "serverInfo": { "name": "tix", "version": server_version },
        "instructions": "tix issue tracker — see each tool's description/inputSchema"
    })
}

/// Read one UTF-8 line from `reader`, returning `None` on EOF (clean
/// shutdown → exit 0, no response). Trailing `\n`/`\r` is stripped so the
/// framing uses exactly one JSON message per line (C-001).
pub fn read_line<R: BufRead>(reader: &mut R) -> io::Result<Option<String>> {
    let mut line = String::new();
    if reader.read_line(&mut line)? == 0 {
        return Ok(None);
    }
    while line.ends_with('\n') || line.ends_with('\r') {
        line.pop();
    }
    Ok(Some(line))
}

/// Write one JSON-RPC message as a single newline-terminated line, then
/// flush. Callers pass the single locked stdout handle owned by
/// [`super::serve`]; nothing else in the crate may write to stdout during a
/// serve, so this helper is the stdout-purity enforcement point (C-005).
pub fn write_message<W: Write, T: Serialize>(writer: &mut W, message: &T) -> io::Result<()> {
    let line = serde_json::to_string(message).map_err(io::Error::other)?;
    writer.write_all(line.as_bytes())?;
    writer.write_all(b"\n")?;
    writer.flush()
}

/// Decode a stdin line into a [`Message`]. A line that is not valid JSON is a
/// [`ParseError::MalformedJson`]; valid JSON that is not a well-formed JSON-RPC
/// message is [`ParseError::InvalidRequest`] carrying the id (if extractable).
pub fn parse_message(line: &str) -> Result<Message, ParseError> {
    let value: Value = serde_json::from_str(line).map_err(|_| ParseError::MalformedJson)?;
    if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
        return Err(ParseError::InvalidRequest(extract_id(&value)));
    }
    let method = value
        .get("method")
        .and_then(Value::as_str)
        .ok_or(ParseError::InvalidRequest(extract_id(&value)))?;
    let params = value.get("params").cloned();
    match extract_id(&value) {
        Some(id) => Ok(Message::Request {
            method: method.to_string(),
            params,
            id,
        }),
        None => Ok(Message::Notification {
            method: method.to_string(),
            params,
        }),
    }
}

/// Pull the `id` field (presence is meaningful: `Some(Id::Null)` is a valid
/// request id; `None` means "notification"). An id of an unusable type is
/// treated as absent so the value still routes as a notification/no-response.
fn extract_id(value: &Value) -> Option<Id> {
    let raw = value.get("id")?;
    if raw.is_object() || raw.is_array() || raw.is_boolean() {
        return None;
    }
    Id::deserialize(raw).ok()
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[test]
    fn request_with_numeric_id_is_a_request() {
        let msg = parse_message(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap();
        assert_eq!(
            msg,
            Message::Request {
                method: "ping".into(),
                params: None,
                id: Id::Number(1),
            }
        );
    }

    #[test]
    fn request_with_string_id_is_a_request() {
        let msg = parse_message(r#"{"jsonrpc":"2.0","id":"a","method":"ping"}"#).unwrap();
        assert!(matches!(
            msg,
            Message::Request { id: Id::String(s), .. } if s == "a"
        ));
    }

    #[test]
    fn explicit_null_id_is_a_request_not_a_notification() {
        // `Some(null)` is a valid JSON-RPC id and must demand a response;
        // indexing into `Option<Id>` would collapse it to an absent id.
        let msg = parse_message(r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#).unwrap();
        assert!(matches!(msg, Message::Request { id: Id::Null, .. }));
    }

    #[test]
    fn absent_id_is_a_notification() {
        let msg =
            parse_message(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).unwrap();
        assert!(
            matches!(msg, Message::Notification { method, .. } if method == "notifications/initialized")
        );
    }

    #[test]
    fn malformed_json_is_parse_error() {
        assert_eq!(
            parse_message("{\"jsonrpc\":\"2.0\", not json"),
            Err(ParseError::MalformedJson)
        );
    }

    #[test]
    fn non_22_jsonrpc_version_is_invalid_request() {
        assert_eq!(
            parse_message(r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#),
            Err(ParseError::InvalidRequest(Some(Id::Number(1))))
        );
    }

    #[test]
    fn missing_method_is_invalid_request_with_id() {
        assert_eq!(
            parse_message(r#"{"jsonrpc":"2.0","id":7}"#),
            Err(ParseError::InvalidRequest(Some(Id::Number(7))))
        );
    }

    #[test]
    fn invalid_id_type_is_treated_as_absent_id() {
        assert!(matches!(
            parse_message(r#"{"jsonrpc":"2.0","id":true,"method":"ping"}"#),
            Ok(Message::Notification { method, .. }) if method == "ping"
        ));
    }

    #[test]
    fn request_carries_params_value() {
        let msg = parse_message(
            r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
        )
        .unwrap();
        assert!(matches!(msg, Message::Request { method, params, id } if
            method == "initialize"
            && params == Some(json!({"protocolVersion": "2025-06-18"}))
            && id == Id::Number(2)));
    }

    #[test]
    fn version_negotiation_echoes_supported_versions() {
        assert_eq!(negotiate_protocol_version("2025-06-18"), "2025-06-18");
        assert_eq!(negotiate_protocol_version("2025-03-26"), "2025-03-26");
        assert_eq!(negotiate_protocol_version("2024-11-05"), "2024-11-05");
    }

    #[test]
    fn version_negotiation_falls_back_to_latest() {
        assert_eq!(negotiate_protocol_version("2024-01-01"), "2025-06-18");
        assert_eq!(negotiate_protocol_version(""), "2025-06-18");
    }

    #[test]
    fn error_constructors_have_frozen_codes() {
        let id = Id::Number(3);
        assert_eq!(parse_error().id, Id::Null);
        assert_eq!(parse_error().error.code, PARSE_ERROR);
        assert_eq!(invalid_request(id.clone()).error.code, INVALID_REQUEST);
        assert_eq!(method_not_found(id.clone()).error.code, METHOD_NOT_FOUND);
        assert_eq!(invalid_params(id.clone()).error.code, INVALID_PARAMS);
        assert_eq!(internal_error(id.clone()).error.code, INTERNAL_ERROR);
        assert_eq!(invalid_request(id).error.message, "Invalid Request");
    }

    #[test]
    fn success_response_serializes_as_single_2d0_message() {
        let resp = success(Id::Number(1), json!({"ok": true}));
        let mut out: Vec<u8> = Vec::new();
        write_message(&mut out, &resp).unwrap();
        let s = String::from_utf8(out).unwrap();
        assert_eq!(
            s,
            "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n"
        );
    }

    #[test]
    fn error_response_omits_data_when_absent() {
        let resp = method_not_found(Id::Null);
        let mut out: Vec<u8> = Vec::new();
        write_message(&mut out, &resp).unwrap();
        let s = String::from_utf8(out).unwrap();
        assert_eq!(
            s,
            "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}\n"
        );
    }

    #[test]
    fn write_message_never_leaks_bytes_after_the_line() {
        // Byte-level purity: one message, one trailing newline, nothing else.
        let resp = success(Id::String("x".into()), json!({}));
        let mut out: Vec<u8> = Vec::new();
        write_message(&mut out, &resp).unwrap();
        assert_eq!(out.last(), Some(&b'\n'));
        let s = String::from_utf8(out).unwrap();
        assert_eq!(s.matches('\n').count(), 1);
        assert!(s.ends_with('\n'));
    }

    #[test]
    fn read_line_returns_none_on_eof() {
        let mut reader: &[u8] = b"";
        assert_eq!(read_line(&mut reader).unwrap(), None);
    }

    #[test]
    fn read_line_strips_trailing_newline() {
        let mut reader: &[u8] = b"{\"a\":1}\n{\"b\":2}\r\n";
        assert_eq!(read_line(&mut reader).unwrap(), Some("{\"a\":1}".into()));
        assert_eq!(read_line(&mut reader).unwrap(), Some("{\"b\":2}".into()));
        assert_eq!(read_line(&mut reader).unwrap(), None);
    }

    #[test]
    fn initialize_result_shape_matches_contract() {
        let result = initialize_result("2025-06-18", "0.1.0");
        assert_eq!(
            result,
            json!({
                "protocolVersion": "2025-06-18",
                "capabilities": { "tools": { "listChanged": false } },
                "serverInfo": { "name": "tix", "version": "0.1.0" },
                "instructions": "tix issue tracker — see each tool's description/inputSchema"
            })
        );
    }
}