obsidian-mcp 1.0.2

MCP server for Obsidian vaults — direct filesystem access for AI agents
Documentation
#[cfg(any(unix, test))]
use std::path::PathBuf;

use rmcp::ServiceExt;
use tracing_subscriber::EnvFilter;

use obsidian_mcp::client::semantic_daemon::{DaemonConnectPolicy, SemanticDaemonClient};
use obsidian_mcp::config::{Config, SemanticMode, SemanticRuntimeConfig};
use obsidian_mcp::daemon::bootstrap::{BootstrapConfig, ensure_daemon};
use obsidian_mcp::daemon::server::IpcEndpoint;
use obsidian_mcp::error::VaultError;
use obsidian_mcp::tools::{ObsidianMcp, SemanticRuntime};
use obsidian_mcp::vault::Vault;

const DAEMON_DISABLED_BY_WATCH_REASON: &str =
    "semantic daemon disabled because OBSIDIAN_WATCH is false";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config::load()?;
    let semantic_runtime_config = SemanticRuntimeConfig::load_from_env();

    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::new(&config.log_level))
        .with_writer(std::io::stderr)
        .init();

    tracing::info!(vault = %config.vault_path.display(), "starting obsidian-mcp");

    let semantic_runtime = init_semantic_runtime(&config, &semantic_runtime_config).await;
    tracing::info!(
        semantic_mode = semantic_runtime.mode.as_str(),
        daemon_ready = semantic_runtime.daemon_client.is_some(),
        "semantic runtime configured"
    );

    let vault = Vault::open(&config).await?;
    let server = ObsidianMcp::new(vault, config.hybrid_alpha, semantic_runtime)
        .serve(rmcp::transport::io::stdio())
        .await?;

    server.waiting().await?;
    Ok(())
}

struct InitializedDaemonClient {
    client: SemanticDaemonClient,
    #[cfg(feature = "embeddings")]
    semantic_home: Option<PathBuf>,
}

async fn init_semantic_runtime(
    config: &Config,
    runtime_cfg: &SemanticRuntimeConfig,
) -> SemanticRuntime {
    let mut runtime = SemanticRuntime {
        mode: runtime_cfg.mode,
        daemon_client: None,
        daemon_unavailable_reason: None,
        prefetch_count: runtime_cfg.prefetch_count,
    };

    if runtime_cfg.mode == SemanticMode::Local {
        return runtime;
    }
    if !config.watch {
        runtime.daemon_unavailable_reason = Some(DAEMON_DISABLED_BY_WATCH_REASON.to_string());
        tracing::info!("semantic daemon disabled because OBSIDIAN_WATCH=false");
        return runtime;
    }

    match initialize_daemon_client(runtime_cfg).await {
        Ok(initialized) => {
            #[cfg(feature = "embeddings")]
            if let Some(semantic_home) = initialized.semantic_home.as_deref() {
                match obsidian_mcp::vault::embeddings::migrate_legacy_cache_to_daemon_store(
                    &config.vault_path,
                    semantic_home,
                ) {
                    Ok(obsidian_mcp::vault::embeddings::LegacyCacheMigration::Migrated(path)) => {
                        tracing::info!(
                            path = %path.display(),
                            "migrated legacy local embedding cache into daemon namespace store"
                        );
                    }
                    Ok(obsidian_mcp::vault::embeddings::LegacyCacheMigration::AlreadyPresent(
                        path,
                    )) => {
                        tracing::debug!(
                            path = %path.display(),
                            "daemon embedding cache already present; skipping legacy cache migration"
                        );
                    }
                    Ok(obsidian_mcp::vault::embeddings::LegacyCacheMigration::NotFound) => {}
                    Err(err) => {
                        tracing::warn!(
                            error = %err,
                            "failed to migrate legacy embedding cache to daemon namespace"
                        );
                    }
                }
            }

            runtime.daemon_client = Some(initialized.client);
        }
        Err(err) => {
            let reason = err.to_string();
            runtime.daemon_unavailable_reason = Some(reason.clone());
            match runtime_cfg.mode {
                SemanticMode::Daemon => {
                    tracing::error!(
                        error = %reason,
                        "semantic daemon mode requested but daemon is unavailable"
                    );
                }
                SemanticMode::Auto => {
                    tracing::warn!(
                        error = %reason,
                        "semantic daemon unavailable; auto mode may fall back to local backend"
                    );
                }
                SemanticMode::Local => {}
            }
        }
    }

    runtime
}

async fn initialize_daemon_client(
    runtime_cfg: &SemanticRuntimeConfig,
) -> Result<InitializedDaemonClient, VaultError> {
    let policy = DaemonConnectPolicy::from_runtime_config(runtime_cfg);
    let initialized = if let Some(raw_endpoint) = runtime_cfg.daemon_endpoint_override.as_deref() {
        InitializedDaemonClient {
            client: SemanticDaemonClient::new(endpoint_from_override(raw_endpoint), policy),
            #[cfg(feature = "embeddings")]
            semantic_home: None,
        }
    } else {
        let bootstrap_result = ensure_daemon(&BootstrapConfig {
            semantic_home_override: runtime_cfg.semantic_home_override.clone(),
            daemon_path_override: runtime_cfg.daemon_path_override.clone(),
            model_name: runtime_cfg.model_name.clone(),
            download_url_override: runtime_cfg.daemon_download_url.clone(),
            bootstrap_client_name: "obsidian-mcp".to_string(),
            bootstrap_client_version: env!("CARGO_PKG_VERSION").to_string(),
        })
        .await?;
        InitializedDaemonClient {
            client: SemanticDaemonClient::new(bootstrap_result.endpoint, policy),
            #[cfg(feature = "embeddings")]
            semantic_home: Some(bootstrap_result.semantic_home),
        }
    };

    let health = initialized
        .client
        .health("obsidian-mcp", env!("CARGO_PKG_VERSION"))
        .await?;
    tracing::info!(
        daemon_version = %health.daemon_version,
        daemon_api_version = health.daemon_api_version,
        daemon_status = %health.status,
        daemon_semantic_home = %health.semantic_home,
        "semantic daemon connection established"
    );
    Ok(initialized)
}

fn endpoint_from_override(raw: &str) -> IpcEndpoint {
    #[cfg(unix)]
    {
        IpcEndpoint::UnixSocket(PathBuf::from(raw))
    }
    #[cfg(windows)]
    {
        IpcEndpoint::NamedPipe(raw.to_string())
    }
}

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

    fn runtime_config(mode: SemanticMode) -> SemanticRuntimeConfig {
        SemanticRuntimeConfig {
            mode,
            semantic_home_override: None,
            daemon_path_override: None,
            daemon_endpoint_override: Some("/tmp/semanticd.sock".to_string()),
            daemon_download_url: None,
            model_name: "BAAI/bge-small-en-v1.5".to_string(),
            connect_timeout_ms: 2_000,
            connect_retries: 2,
            retry_backoff_ms: 250,
            prefetch_count: 50,
        }
    }

    #[tokio::test]
    async fn watch_disabled_skips_daemon_initialization() {
        let config = Config {
            vault_path: PathBuf::from("/tmp/test-vault"),
            watch: false,
            log_level: "error".to_string(),
            tantivy: true,
            embeddings: false,
            embeddings_model: "BAAI/bge-small-en-v1.5".to_string(),
            hybrid_alpha: 0.25,
        };
        let runtime = init_semantic_runtime(&config, &runtime_config(SemanticMode::Daemon)).await;
        assert!(runtime.daemon_client.is_none());
        assert_eq!(
            runtime.daemon_unavailable_reason.as_deref(),
            Some(DAEMON_DISABLED_BY_WATCH_REASON)
        );
    }
}