grepdown-mcp 0.1.0

MCP server for grepdown: exposes Markdown knowledge base search, traversal, and linting to AI agents over stdio
use grepdown_lib::{DocumentContent, Link, MDDBProject, ReachableNode, approve_edits, run_lints};
use rmcp::{ServerHandler, handler::server::wrapper::Parameters, tool, tool_handler, tool_router};
use std::sync::Arc;
use tokio::sync::Mutex;

#[derive(Clone)]
pub struct GrepdownMCP {
    project: Arc<Mutex<MDDBProject>>,
}

impl GrepdownMCP {
    pub fn new(project: Arc<Mutex<MDDBProject>>) -> Self {
        Self { project }
    }
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
    /// Full-text search query to match against document content
    #[schemars(description = "Full-text search query to match against document content")]
    pub query: String,
    /// Maximum number of results to return (default: 20)
    #[schemars(description = "Maximum number of results to return (default: 20)")]
    pub limit: Option<usize>,
    /// Restrict results to documents whose path contains this substring
    #[schemars(description = "Restrict results to documents whose path contains this substring")]
    pub path_filter: Option<String>,
    /// Number of tokens to include in search snippets (default: 32)
    #[schemars(description = "Number of tokens to include in search snippets (default: 32)")]
    pub snippet_length: Option<i64>,
    /// Automatically refresh the index before searching (default: true)
    #[schemars(description = "Automatically refresh the index before searching (default: true)")]
    pub auto_refresh: Option<bool>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DocIdParams {
    /// Unique document identifier (typically the file path relative to the project root)
    #[schemars(
        description = "Unique document identifier (typically the file path relative to the project root)"
    )]
    pub doc_id: String,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ApproveEditsParams {
    /// Only approve stale references in these paths. Empty or omitted means approve all.
    #[schemars(
        description = "Only approve stale references in these paths. Empty or omitted means approve all."
    )]
    pub paths: Option<Vec<String>>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ReachableParams {
    /// Starting document identifier (typically the file path relative to the project root)
    #[schemars(
        description = "Starting document identifier (typically the file path relative to the project root)"
    )]
    pub doc_id: String,
    /// Maximum hop depth for BFS traversal (default: 2)
    #[schemars(description = "Maximum hop depth for BFS traversal (default: 2)")]
    pub max_depth: Option<i64>,
}

#[tool_router(vis = "pub")]
impl GrepdownMCP {
    #[tool(
        description = "Search the knowledge base using full-text search. Returns matching documents ranked by relevance. By default, automatically refreshes the index before searching — set auto_refresh to false to skip this if you know the index is fresh.",
        annotations(
            title = "Search documents",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn search(
        &self,
        Parameters(SearchParams {
            query,
            limit,
            path_filter,
            snippet_length,
            auto_refresh,
        }): Parameters<SearchParams>,
    ) -> Result<String, String> {
        let project = self.project.lock().await;

        // Auto-refresh by default (aligns with CLI behavior)
        if auto_refresh.unwrap_or(true) {
            project.refresh().map_err(|e| e.to_string())?;
        }

        let results = project
            .search(
                &query,
                limit.unwrap_or(20),
                path_filter.as_deref(),
                snippet_length,
            )
            .map_err(|e| e.to_string())?;

        serde_json::to_string(&results).map_err(|e| format!("failed to serialize: {e}"))
    }

    #[tool(
        description = "Re-index the project. Scans for new, modified, or deleted files and updates the search index. Returns changed file paths and their new mtimes. Call this before `search` when files may have changed.",
        annotations(
            title = "Refresh index",
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn refresh(&self) -> Result<String, String> {
        let project = self.project.lock().await;
        let result = project.refresh().map_err(|e| e.to_string())?;

        serde_json::to_string(&result).map_err(|e| format!("serialization error: {e}"))
    }

    #[tool(
        description = "Run knowledge base lints and return diagnostics. Detects issues such as broken links, stale references, and orphaned documents. Use this to audit KB health.",
        annotations(
            title = "Lint knowledge base",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn lint(&self) -> Result<String, String> {
        let project = self.project.lock().await;
        let conn = project.get_conn();
        let result = run_lints(conn).map_err(|e| e.to_string())?;

        serde_json::to_string(&result).map_err(|e| format!("serialization error: {e}"))
    }

    #[tool(
        description = "Approve stale references in the knowledge base. Marks outdated link targets as accepted. When `paths` is omitted or empty, approves all stale references. When `paths` is provided, only approves stale references in those specific documents. Use after reviewing lint diagnostics.",
        annotations(
            title = "Approve stale references",
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn approve_edits(
        &self,
        Parameters(ApproveEditsParams { paths }): Parameters<ApproveEditsParams>,
    ) -> Result<String, String> {
        let project = self.project.lock().await;
        let conn = project.get_conn();
        let count = approve_edits(conn, &paths.unwrap_or_default()).map_err(|e| e.to_string())?;

        serde_json::to_string(&count).map_err(|e| format!("serialization error: {e}"))
    }

    #[tool(
        description = "Get outgoing links (documents this one links to) and incoming links (backlinks: documents linking to this one) for a given document. Useful for understanding document relationships and navigation.",
        annotations(
            title = "Get document links",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn get_links(
        &self,
        Parameters(DocIdParams { doc_id }): Parameters<DocIdParams>,
    ) -> Result<String, String> {
        let project = self.project.lock().await;
        let outgoing = project.get_links_from(&doc_id).map_err(|e| e.to_string())?;
        let incoming = project.get_links_to(&doc_id).map_err(|e| e.to_string())?;

        #[derive(serde::Serialize)]
        struct LinkResult {
            outgoing: Vec<Link>,
            incoming: Vec<Link>,
        }

        serde_json::to_string(&LinkResult { outgoing, incoming })
            .map_err(|e| format!("serialization error: {e}"))
    }

    #[tool(
        description = "Get external URLs (HTTP/HTTPS citations) referenced in a document. Useful for finding source material or verifying references.",
        annotations(
            title = "Get document citations",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn get_citations(
        &self,
        Parameters(DocIdParams { doc_id }): Parameters<DocIdParams>,
    ) -> Result<String, String> {
        let project = self.project.lock().await;
        let result = project
            .get_citations_from(&doc_id)
            .map_err(|e| e.to_string())?;

        serde_json::to_string(&result).map_err(|e| format!("serialization error: {e}"))
    }

    #[tool(
        description = "Get all documents reachable from a starting document via the link graph using BFS traversal. Returns each reachable document with its minimum hop depth. Useful for exploring knowledge neighborhoods and understanding document clusters.",
        annotations(
            title = "Get reachable documents",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn get_reachable(
        &self,
        Parameters(ReachableParams { doc_id, max_depth }): Parameters<ReachableParams>,
    ) -> Result<String, String> {
        let project = self.project.lock().await;
        let result: Vec<ReachableNode> = project
            .get_reachable(&doc_id, max_depth.unwrap_or(2))
            .map_err(|e| e.to_string())?;

        serde_json::to_string(&result).map_err(|e| format!("serialization error: {e}"))
    }

    #[tool(
        description = "Read the full content of a document and its frontmatter tags. Use after `search` to retrieve the complete text of a matching document. The doc_id is the document path relative to the project root.",
        annotations(
            title = "Read document content",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn get_document(
        &self,
        Parameters(DocIdParams { doc_id }): Parameters<DocIdParams>,
    ) -> Result<String, String> {
        let project = self.project.lock().await;
        let result: DocumentContent = project.read_document(&doc_id).map_err(|e| e.to_string())?;

        serde_json::to_string(&result).map_err(|e| format!("serialization error: {e}"))
    }
}

#[tool_handler(
    name = "grepdown",
    version = "0.1.0",
    instructions = "Markdown knowledge base management. Typical workflow: 1) Call `refresh` to sync the index with the filesystem. 2) Use `search` for full-text queries. 3) Use `get_document` to read the full content and tags of a specific document. 4) Use `get_links` to explore document relationships (forward links and backlinks). 5) Use `get_citations` to find external URLs in a document. 6) Use `get_reachable` to explore knowledge neighborhoods via BFS traversal of the link graph. 7) Use `lint` to audit KB health and find stale/broken references. 8) Use `approve_edits` to mark stale references as reviewed after fixing them. All write operations (`refresh`, `approve_edits`) are safe and idempotent."
)]
impl ServerHandler for GrepdownMCP {}