tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! MCP server entry point and dispatch loop (`tix mcp`).
//!
//! `serve` owns the synchronous, sequential stdin→stdout loop described in
//! [`contracts/mcp-protocol.md`] §1/§8 (D-01). Bootstrap — including the
//! fast-fail outside a repository — happens in the CLI layer
//! (`cli::run_mcp`), so `serve` receives a validated [`crate::config::Config`]
//! and an open [`crate::storage::Store`] and never prints to `stdout` directly
//! (`C-005`).

pub mod protocol;
pub mod tools;

use std::io::stdin;
use std::io::stdout;

use serde_json::{Value, json};

use crate::config::Config;
use crate::error::Result;
use crate::storage::Store;

/// All outbound wire variants serialized by [`handle_message`]:
/// `Response` on success, `ErrorResponse` on failure.
#[derive(Debug, Clone)]
enum Outbound {
    Success(protocol::Response),
    Error(protocol::ErrorResponse),
}

impl serde::Serialize for Outbound {
    fn serialize<S: serde::Serializer>(
        &self,
        serializer: S,
    ) -> std::result::Result<S::Ok, S::Error> {
        match self {
            Outbound::Success(r) => r.serialize(serializer),
            Outbound::Error(e) => e.serialize(serializer),
        }
    }
}

/// Run the MCP server synchronously over newline-delimited stdio (D-01).
///
/// Returns `Ok(())` on clean `stdin` EOF (`exit 0`); errors only when a
/// read/write failure occurs (which we classify as `-32603` at the protocol
/// layer — the server never dies on a failing tool).
pub fn serve(config: &Config, store: &Store) -> Result<()> {
    let stdin = stdin();
    let stdout = stdout();
    let mut reader = stdin.lock();
    let mut writer = stdout.lock();
    loop {
        let line = match protocol::read_line(&mut reader)? {
            Some(line) => line,
            None => return Ok(()),
        };
        if let Some(outbound) = handle_message(config, store, &line) {
            protocol::write_message(&mut writer, &outbound)?;
        }
        emit_disclosures();
    }
}

/// Per request, on stderr.
///
/// A write performed by a tool call raises its disclosure on this thread, and it
/// must neither survive into the next request — where it would be attributed to
/// an operation that did not cause it — nor be lost entirely.
///
/// Extracted from the serve loop so the *placement* is testable. Cycle 5 found
/// that `mcp_disclosures_do_not_leak_across_requests` passed with the drain moved
/// to session end, so "exactly once" was pinned while "per request" — the entire
/// reason this sits inside the loop — was not. A drain inlined in the loop has
/// no observable seam at all.
pub fn emit_disclosures() {
    for disclosure in crate::storage::take_disclosures() {
        eprintln!(
            "{}",
            crate::diagnostics::Diagnostic::warning("W-tix-disclosure", disclosure).render_auto()
        );
    }
}

/// Dispatch a single stdin line into a response (if any). Notifications always
/// map to `None`; malformed/unknown methods map to the frozen error matrix.
fn handle_message(config: &Config, store: &Store, line: &str) -> Option<Outbound> {
    match protocol::parse_message(line) {
        Ok(protocol::Message::Request { method, params, id }) => match method.as_str() {
            "initialize" => {
                let requested = params
                    .as_ref()
                    .and_then(|p| p.get("protocolVersion"))
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                let version = protocol::negotiate_protocol_version(requested);
                let result = protocol::initialize_result(version, env!("CARGO_PKG_VERSION"));
                Some(Outbound::Success(protocol::success(id, result)))
            }
            // notifications/initialized is technically a notification, but the
            // MCP contract says "no response" unconditionally for this method,
            // so we treat a request-shaped one the same: silently drop.
            "notifications/initialized" => None,
            "ping" => Some(Outbound::Success(protocol::success(id, json!({})))),
            "tools/list" => Some(Outbound::Success(protocol::success(
                id,
                json!({ "tools": tools::list() }),
            ))),
            "tools/call" => {
                let params = params.unwrap_or(json!({}));
                let name = params.get("name").and_then(Value::as_str);
                let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
                let name = match name {
                    Some(name) => name,
                    None => return Some(Outbound::Error(protocol::invalid_params(id))),
                };
                match tools::call(config, store, id.clone(), name, &arguments) {
                    Ok(result) => Some(Outbound::Success(protocol::success(id, result))),
                    Err(error) => Some(Outbound::Error(error)),
                }
            }
            _ => Some(Outbound::Error(protocol::method_not_found(id))),
        },
        Ok(protocol::Message::Notification { method, .. }) => {
            // All notifications (including `notifications/initialized`) produce
            // no response — per the MCP spec §3 and `contracts/mcp-protocol.md`
            // §1 ("id absent ⇒ notification (no response)").
            let _ = method;
            None
        }
        Err(protocol::ParseError::MalformedJson) => Some(Outbound::Error(protocol::parse_error())),
        Err(protocol::ParseError::InvalidRequest(maybe_id)) => {
            maybe_id.map(|id| Outbound::Error(protocol::invalid_request(id)))
        }
    }
}

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

    fn test_config() -> Config {
        crate::config::Config::default()
    }

    fn test_store() -> Store {
        Store {
            issues_dir: std::env::temp_dir(),
            config: crate::config::Config::default(),
        }
    }

    fn from_line(line: &str) -> Option<Value> {
        let out = handle_message(&test_config(), &test_store(), line)?;
        Some(serde_json::to_value(&out).unwrap())
    }

    #[test]
    fn initialize_echoes_supported_protocol_version() {
        let resp = from_line(
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}}"#,
        )
        .unwrap();
        assert_eq!(
            resp["result"]["protocolVersion"],
            serde_json::Value::String("2025-03-26".into())
        );
        assert_eq!(
            resp["result"]["serverInfo"]["name"],
            serde_json::Value::String("tix".into())
        );
    }

    #[test]
    fn initialize_negotiates_unsupported_to_latest() {
        let resp = from_line(
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2019-01-01","capabilities":{}}}"#,
        )
        .unwrap();
        assert_eq!(
            resp["result"]["protocolVersion"],
            serde_json::Value::String("2025-06-18".into())
        );
    }

    #[test]
    fn notifications_initialized_is_silently_ignored() {
        let resp = from_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
        assert!(resp.is_none(), "must produce no response");
    }

    #[test]
    fn ping_returns_empty_object() {
        let resp = from_line(r#"{"jsonrpc":"2.0","id":42,"method":"ping"}"#).unwrap();
        assert_eq!(resp["result"], json!({}));
    }

    /// The tool count is asserted against the registry, not against a literal.
    ///
    /// A hardcoded count is a tripwire that has to be edited on every tool
    /// change, and editing it is exactly the moment a mistake is easiest. The
    /// previous `assert_eq!(tools.len(), 13)` plus a hand-maintained name filter
    /// is what let a declared-but-undispatched tool survive. Deriving the
    /// expectation from the registry removes the number to get wrong.
    #[test]
    fn tools_list_returns_every_registered_tool() {
        let resp = from_line(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#).unwrap();
        let tools = resp["result"]["tools"].as_array().unwrap();
        let declared: Vec<&str> = tools::registry().iter().map(|t| t.name).collect();
        let listed: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
        assert_eq!(
            listed, declared,
            "tools/list must advertise exactly the registry, in the registry's order"
        );
        for required in [
            "list_relationships",
            "edit_relationship",
            "remove_relationship",
            "issue_chain",
            "issue_blocked_state",
        ] {
            assert!(
                listed.contains(&required),
                "the WP05 relationship tool `{required}` must be advertised: {listed:?}"
            );
        }
    }
    #[test]
    fn tools_call_unknown_tool_is_method_not_found() {
        let resp = from_line(
            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nonexistent","arguments":{}}}"#,
        )
        .unwrap();
        assert_eq!(resp["error"]["code"], -32601);
    }

    #[test]
    fn tools_call_missing_name_is_invalid_params() {
        let resp = from_line(
            r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"arguments":{}}}"#,
        )
        .unwrap();
        assert_eq!(resp["error"]["code"], -32602);
    }

    #[test]
    fn unknown_method_is_method_not_found() {
        let resp = from_line(r#"{"jsonrpc":"2.0","id":4,"method":"bogus"}"#).unwrap();
        assert_eq!(resp["error"]["code"], -32601);
    }

    #[test]
    fn malformed_json_is_parse_error_with_null_id() {
        let resp = from_line("not json").unwrap();
        assert_eq!(resp["error"]["code"], -32700);
        assert_eq!(resp["id"], json!(null));
    }

    #[test]
    fn missing_method_is_invalid_request_with_id() {
        let resp = from_line(r#"{"jsonrpc":"2.0","id":5}"#).unwrap();
        assert_eq!(resp["error"]["code"], -32600);
        assert_eq!(resp["id"], 5);
    }

    #[test]
    fn missing_method_without_id_produces_no_response() {
        let resp = from_line(r#"{"jsonrpc":"2.0"}"#);
        assert!(resp.is_none(), "notification with no id: no response");
    }

    #[test]
    fn non_2d0_jsonrpc_version_is_invalid_request() {
        let resp = from_line(r#"{"jsonrpc":"1.0","id":6,"method":"ping"}"#).unwrap();
        assert_eq!(resp["error"]["code"], -32600);
        assert_eq!(resp["id"], 6);
    }

    #[test]
    fn notify_unknown_method_silently_dropped() {
        let resp = from_line(r#"{"jsonrpc":"2.0","method":"unknown/notification"}"#);
        assert!(resp.is_none());
    }
}