task-journal-mcp 0.1.1

MCP server for task-journal: exposes task_pack, task_create, event_add, task_close, task_search to Claude Code and other MCP-compatible agents.
//! task-journal-mcp: MCP server entry point.
//!
//! Phase 2 wires real implementations into all 5 tools, calling tj-core.

use anyhow::Result;
use rmcp::{
    handler::server::tool::Parameters, handler::server::wrapper::Json, tool, tool_handler,
    tool_router, transport::io::stdio, ServerHandler, ServiceExt,
};
use serde::{Deserialize, Serialize};
use std::future::Future;

#[derive(Clone, Default)]
pub struct TaskJournalServer;

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TaskPackParams {
    pub task_id: String,
    pub mode: Option<String>,
}

#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskPackResult {
    pub task_id: String,
    pub mode: String,
    pub schema_version: String,
    pub text: String,
    pub metadata: TaskPackMetadata,
}

#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskPackMetadata {
    pub stub: bool,
    pub source_event_count: Option<usize>,
    pub cache_hit: Option<bool>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TaskSearchParams {
    pub query: String,
    pub status: Option<String>,
    pub project: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskSearchResult {
    pub query: String,
    pub results: Vec<String>,
    pub stub: bool,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TaskCreateParams {
    pub title: String,
    pub initial_context: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskCreateResult {
    pub task_id: String,
    pub title: String,
    pub stub: bool,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EventAddParams {
    pub task_id: String,
    pub event_type: String,
    pub text: String,
    pub corrects: Option<String>,
    pub supersedes: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct EventAddResult {
    pub event_id: String,
    pub task_id: String,
    pub event_type: String,
    pub stub: bool,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TaskCloseParams {
    pub task_id: String,
    pub reason: String,
    pub outcome: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct TaskCloseResult {
    pub task_id: String,
    pub closed: bool,
    pub stub: bool,
}

fn parse_event_type(s: &str) -> anyhow::Result<tj_core::event::EventType> {
    use tj_core::event::EventType::*;
    Ok(match s {
        "open" => Open,
        "hypothesis" => Hypothesis,
        "finding" => Finding,
        "evidence" => Evidence,
        "decision" => Decision,
        "rejection" => Rejection,
        "constraint" => Constraint,
        "correction" => Correction,
        "reopen" => Reopen,
        "supersede" => Supersede,
        "close" => Close,
        "redirect" => Redirect,
        other => anyhow::bail!("unknown event type: {other}"),
    })
}

fn project_paths() -> anyhow::Result<(String, std::path::PathBuf, std::path::PathBuf)> {
    let cwd = std::env::current_dir()?;
    let project_hash = tj_core::project_hash::from_path(&cwd)?;
    let events = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
    let state = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite"));
    Ok((project_hash, events, state))
}

#[tool_router]
impl TaskJournalServer {
    #[tool(
        name = "task_pack",
        description = "Return a compact resume pack for a task. Pass mode=compact|full."
    )]
    async fn task_pack(&self, Parameters(p): Parameters<TaskPackParams>) -> Json<TaskPackResult> {
        let result = (|| -> anyhow::Result<TaskPackResult> {
            let (project_hash, events_path, state_path) = project_paths()?;
            let conn = tj_core::db::open(&state_path)?;
            if events_path.exists() {
                tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?;
            }
            let pmode = match p.mode.as_deref() {
                Some("full") => tj_core::pack::PackMode::Full,
                _ => tj_core::pack::PackMode::Compact,
            };
            let pack = tj_core::pack::assemble(&conn, &p.task_id, pmode)?;
            Ok(TaskPackResult {
                task_id: pack.task_id,
                mode: match pack.mode {
                    tj_core::pack::PackMode::Compact => "compact".into(),
                    tj_core::pack::PackMode::Full => "full".into(),
                },
                schema_version: pack.schema_version,
                text: pack.text,
                metadata: TaskPackMetadata {
                    stub: false,
                    source_event_count: Some(pack.metadata.source_event_count),
                    cache_hit: Some(pack.metadata.cache_hit),
                },
            })
        })();
        match result {
            Ok(r) => Json(r),
            Err(e) => Json(TaskPackResult {
                task_id: p.task_id,
                mode: p.mode.unwrap_or_else(|| "compact".into()),
                schema_version: "1.0".into(),
                text: format!("[error] {e}"),
                metadata: TaskPackMetadata {
                    stub: false,
                    source_event_count: None,
                    cache_hit: None,
                },
            }),
        }
    }

    #[tool(
        name = "task_search",
        description = "Full-text search tasks by query (FTS5)."
    )]
    async fn task_search(
        &self,
        Parameters(p): Parameters<TaskSearchParams>,
    ) -> Json<TaskSearchResult> {
        let result = (|| -> anyhow::Result<Vec<String>> {
            let (project_hash, events_path, state_path) = project_paths()?;
            let conn = tj_core::db::open(&state_path)?;
            if events_path.exists() {
                tj_core::db::rebuild_state(&conn, &events_path, &project_hash)?;
            }
            let mut stmt = conn.prepare(
                "SELECT DISTINCT task_id FROM search_fts WHERE search_fts MATCH ?1 LIMIT 50",
            )?;
            let ids: Vec<String> = stmt
                .query_map(rusqlite::params![p.query], |r| r.get::<_, String>(0))?
                .collect::<Result<_, _>>()?;
            Ok(ids)
        })();
        Json(TaskSearchResult {
            query: p.query,
            results: result.unwrap_or_default(),
            stub: false,
        })
    }

    #[tool(
        name = "task_create",
        description = "Open a new task with title and optional initial context."
    )]
    async fn task_create(
        &self,
        Parameters(p): Parameters<TaskCreateParams>,
    ) -> Json<TaskCreateResult> {
        let result = (|| -> anyhow::Result<TaskCreateResult> {
            let (_, events_path, _) = project_paths()?;
            std::fs::create_dir_all(events_path.parent().unwrap())?;

            let task_id = format!(
                "tj-{}",
                &ulid::Ulid::new().to_string()[10..16].to_lowercase()
            );
            let mut event = tj_core::event::Event::new(
                task_id.clone(),
                tj_core::event::EventType::Open,
                tj_core::event::Author::Agent,
                tj_core::event::Source::Chat,
                p.initial_context.clone().unwrap_or_else(|| p.title.clone()),
            );
            event.meta = serde_json::json!({"title": p.title.clone()});

            let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
            writer.append(&event)?;
            writer.flush_durable()?;

            Ok(TaskCreateResult {
                task_id,
                title: p.title.clone(),
                stub: false,
            })
        })();
        Json(result.unwrap_or_else(|e| TaskCreateResult {
            task_id: format!("[error] {e}"),
            title: p.title,
            stub: false,
        }))
    }

    #[tool(
        name = "event_add",
        description = "Append a typed event (decision, finding, evidence, rejection, etc.) to a task."
    )]
    async fn event_add(&self, Parameters(p): Parameters<EventAddParams>) -> Json<EventAddResult> {
        let result = (|| -> anyhow::Result<EventAddResult> {
            let (_, events_path, _) = project_paths()?;
            std::fs::create_dir_all(events_path.parent().unwrap())?;

            let event_type = parse_event_type(&p.event_type)?;
            let mut event = tj_core::event::Event::new(
                &p.task_id,
                event_type,
                tj_core::event::Author::Agent,
                tj_core::event::Source::Chat,
                p.text.clone(),
            );
            event.corrects = p.corrects.clone();
            event.supersedes = p.supersedes.clone();

            let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
            writer.append(&event)?;
            writer.flush_durable()?;

            Ok(EventAddResult {
                event_id: event.event_id,
                task_id: p.task_id.clone(),
                event_type: p.event_type.clone(),
                stub: false,
            })
        })();
        Json(result.unwrap_or_else(|e| EventAddResult {
            event_id: format!("[error] {e}"),
            task_id: p.task_id,
            event_type: p.event_type,
            stub: false,
        }))
    }

    #[tool(
        name = "task_close",
        description = "Close a task with reason and outcome."
    )]
    async fn task_close(
        &self,
        Parameters(p): Parameters<TaskCloseParams>,
    ) -> Json<TaskCloseResult> {
        let result = (|| -> anyhow::Result<()> {
            let (_, events_path, _) = project_paths()?;
            let mut event = tj_core::event::Event::new(
                &p.task_id,
                tj_core::event::EventType::Close,
                tj_core::event::Author::Agent,
                tj_core::event::Source::Chat,
                p.reason.clone(),
            );
            let mut meta = serde_json::Map::new();
            meta.insert("reason".into(), serde_json::Value::String(p.reason.clone()));
            if let Some(o) = &p.outcome {
                meta.insert("outcome".into(), serde_json::Value::String(o.clone()));
            }
            event.meta = serde_json::Value::Object(meta);

            let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
            writer.append(&event)?;
            writer.flush_durable()?;
            Ok(())
        })();
        Json(TaskCloseResult {
            task_id: p.task_id,
            closed: result.is_ok(),
            stub: false,
        })
    }
}

#[tool_handler(router = Self::tool_router())]
impl ServerHandler for TaskJournalServer {
    fn get_info(&self) -> rmcp::model::ServerInfo {
        rmcp::model::ServerInfo {
            server_info: rmcp::model::Implementation {
                name: "task-journal".into(),
                version: env!("CARGO_PKG_VERSION").into(),
            },
            capabilities: rmcp::model::ServerCapabilities::builder()
                .enable_tools()
                .build(),
            ..Default::default()
        }
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .with_writer(std::io::stderr)
        .init();

    let server = TaskJournalServer;
    let (stdin, stdout) = stdio();
    server.serve((stdin, stdout)).await?.waiting().await?;
    Ok(())
}