task-mcp 0.5.0

MCP server for task runner integration — Agent-safe harness for defined tasks
Documentation
/// E2E tests that spawn the real task-mcp binary and communicate via Stdio MCP
/// protocol.  These tests require both `just` and the compiled `task-mcp` binary.
#[path = "common/mod.rs"]
mod common;

/// Spawn task-mcp with TASK_MCP_LOAD_GLOBAL=true pointing to a global justfile,
/// then perform the full MCP handshake + session_start + list and verify that
/// the global recipe appears in the response.
#[test]
#[ignore = "requires just binary and built task-mcp binary"]
fn e2e_stdio_list_returns_global_recipe() {
    use std::io::{BufRead as _, Write as _};
    use std::process::{Command, Stdio};

    let project =
        common::TempProject::with_global_recipes("# [allow-agent]\nhello:\n    echo from-global\n");
    let global_justfile = project.global_justfile().expect("global justfile exists");
    let project_dir = project.project_path().to_path_buf();

    let bin = env!("CARGO_BIN_EXE_task-mcp");

    let mut child = Command::new(bin)
        .arg("--mcp")
        .env("TASK_MCP_LOAD_GLOBAL", "true")
        .env("TASK_MCP_GLOBAL_JUSTFILE", &global_justfile)
        .env("TASK_MCP_MODE", "all")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .expect("failed to spawn task-mcp binary");

    let stdin = child.stdin.as_mut().expect("stdin should be available");
    let stdout = child.stdout.take().expect("stdout should be available");
    let mut reader = std::io::BufReader::new(stdout);

    // 1. initialize
    let init_msg = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
            "protocolVersion": "2025-03-26",
            "capabilities": {},
            "clientInfo": { "name": "e2e-test", "version": "0.0.1" }
        }
    }))
    .expect("serialize initialize");
    writeln!(stdin, "{init_msg}").expect("write initialize");

    let mut init_resp_line = String::new();
    reader
        .read_line(&mut init_resp_line)
        .expect("read initialize response");
    let init_resp: serde_json::Value =
        serde_json::from_str(init_resp_line.trim()).expect("parse initialize response");
    assert_eq!(init_resp["id"], 1, "initialize id mismatch");
    assert!(
        init_resp.get("error").is_none(),
        "initialize must not return error: {init_resp}"
    );

    // 2. notifications/initialized
    let notif_msg = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "method": "notifications/initialized",
        "params": {}
    }))
    .expect("serialize notifications/initialized");
    writeln!(stdin, "{notif_msg}").expect("write notifications/initialized");

    // 3. session_start
    let session_msg = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
            "name": "session_start",
            "arguments": { "workdir": project_dir.to_string_lossy() }
        }
    }))
    .expect("serialize session_start");
    writeln!(stdin, "{session_msg}").expect("write session_start");

    let mut session_resp_line = String::new();
    reader
        .read_line(&mut session_resp_line)
        .expect("read session_start response");
    let session_resp: serde_json::Value =
        serde_json::from_str(session_resp_line.trim()).expect("parse session_start response");
    assert_eq!(session_resp["id"], 2, "session_start id mismatch");
    assert!(
        session_resp.get("error").is_none(),
        "session_start must not return error: {session_resp}"
    );

    // 4. list
    let list_msg = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "id": 3,
        "method": "tools/call",
        "params": {
            "name": "list",
            "arguments": {}
        }
    }))
    .expect("serialize list");
    writeln!(stdin, "{list_msg}").expect("write list");

    let mut list_resp_line = String::new();
    reader
        .read_line(&mut list_resp_line)
        .expect("read list response");

    let _ = child.kill();
    let _ = child.wait();

    let list_resp: serde_json::Value =
        serde_json::from_str(list_resp_line.trim()).expect("parse list response");
    assert_eq!(list_resp["id"], 3, "list id mismatch");
    assert!(
        list_resp.get("error").is_none(),
        "list must not return error: {list_resp}"
    );

    // The text content is an object `{ "recipes": [...] }`; an optional
    // `auto_session_start` field is present only when this call triggered
    // an automatic session_start (not the case here — we called session_start
    // explicitly in step 3).
    let content_text = list_resp["result"]["content"][0]["text"]
        .as_str()
        .expect("content[0].text should be present");
    let list_payload: serde_json::Value =
        serde_json::from_str(content_text).expect("parse list payload JSON");
    let recipes = list_payload["recipes"]
        .as_array()
        .expect("list payload should contain a `recipes` array");

    let names: Vec<&str> = recipes.iter().filter_map(|r| r["name"].as_str()).collect();

    assert!(
        names.contains(&"hello"),
        "global recipe 'hello' should appear in list result: {names:?}"
    );
}