scryer-mcp 0.2.1

Model Context Protocol (MCP) server for Scryer code intelligence
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;

use scryer_db::{Project, ProjectRegistry};

/// 3-tier waterfall project context resolution for incoming MCP requests.
///
/// 1. **Tier 1 (Canonical Path Matching)**: Longest prefix match against registered `Project.root_path` entries.
/// 2. **Tier 2 (Explicit Project Parameter)**: Slug or ID lookup in `ProjectRegistry`.
/// 3. **Tier 3 (MCP Client Session Root / Active Context)**: Fall back to active project ID or session roots.
#[derive(Clone)]
pub struct ProjectContextResolver {
    registry: Arc<ProjectRegistry>,
    active_project_id: Arc<RwLock<Option<u64>>>,
    session_roots: Arc<RwLock<Vec<PathBuf>>>,
    session_cwd: Arc<RwLock<Option<PathBuf>>>,
}

impl ProjectContextResolver {
    /// Create a new resolver wrapping a `ProjectRegistry`.
    pub fn new(registry: Arc<ProjectRegistry>) -> Self {
        Self {
            registry,
            active_project_id: Arc::new(RwLock::new(None)),
            session_roots: Arc::new(RwLock::new(Vec::new())),
            session_cwd: Arc::new(RwLock::new(None)),
        }
    }

    /// Access the underlying `ProjectRegistry`.
    pub fn registry(&self) -> &Arc<ProjectRegistry> {
        &self.registry
    }

    /// Explicitly override the session's active project context (Tier 3 override).
    pub async fn set_active_project(&self, project_id: u64) {
        let mut guard = self.active_project_id.write().await;
        *guard = Some(project_id);
    }

    /// Clear the session's active project override if it points at `project_id`.
    pub async fn clear_active_project(&self, project_id: u64) {
        let mut guard = self.active_project_id.write().await;
        if *guard == Some(project_id) {
            *guard = None;
        }
    }

    /// Get the current session's active project ID, if set.
    pub async fn get_active_project(&self) -> Option<u64> {
        *self.active_project_id.read().await
    }

    /// Set or update the session workspace roots announced by the MCP client.
    pub async fn set_session_roots(&self, roots: Vec<PathBuf>) {
        let mut guard = self.session_roots.write().await;
        *guard = roots;
    }

    /// Set the client's working directory for this session.
    ///
    /// Relative tool paths are resolved against it, and it seeds the session roots
    /// until the client announces its own roots.
    pub async fn set_session_cwd(&self, cwd: PathBuf) {
        {
            let mut roots = self.session_roots.write().await;
            if roots.is_empty() {
                roots.push(cwd.clone());
            }
        }
        *self.session_cwd.write().await = Some(cwd);
    }

    /// Get the client's working directory for this session, if known.
    pub async fn session_cwd(&self) -> Option<PathBuf> {
        self.session_cwd.read().await.clone()
    }

    /// Join a relative path onto the session working directory.
    ///
    /// Absolute paths, and all paths when no session cwd is known, are returned unchanged.
    pub async fn absolutize(&self, path: &Path) -> PathBuf {
        if path.is_absolute() {
            return path.to_path_buf();
        }
        match self.session_cwd.read().await.as_ref() {
            Some(cwd) => cwd.join(path),
            None => path.to_path_buf(),
        }
    }

    /// Resolve project context using the 3-tier cascade.
    ///
    /// Returns `(Project, Option<PathBuf>)` where the second element is the relative path
    /// of `file_path` within the project root, if applicable.
    pub async fn resolve_project(
        &self,
        file_path: Option<&Path>,
        project_param: Option<&str>,
    ) -> anyhow::Result<(Project, Option<PathBuf>)> {
        let absolute = match file_path {
            Some(path) => Some(self.absolutize(path).await),
            None => None,
        };
        let file_path = absolute.as_deref();

        // An explicit project that matches nothing is an error, never a silent fall back to
        // the active or only project: the agent would read another project's results as the
        // one it asked about.
        let explicit_project = match project_param.map(str::trim).filter(|p| !p.is_empty()) {
            Some(wanted) => Some(self.registry.find(wanted).await.ok_or_else(|| {
                anyhow::anyhow!(
                    "No registered project matches '{wanted}'. Use list_projects to see project slugs, IDs and root paths, or index_workspace to register one."
                )
            })?),
            None => None,
        };

        // Tier 1: Canonical Path Matching
        if let Some(path) = file_path
            && let Some((proj, rel_path)) = self.registry.resolve_path(path).await
        {
            return Ok((proj, Some(rel_path)));
        }

        // Tier 2: Explicit Project Parameter (validated above)
        if let Some(proj) = explicit_project {
            let rel = file_path.and_then(|p| compute_relative(&proj, p));
            return Ok((proj, rel));
        }

        // Tier 3: MCP Client Session Root & Active Project
        // 3a. Active project ID
        let active_id = *self.active_project_id.read().await;
        if let Some(id) = active_id {
            let all = self.registry.list_projects().await;
            if let Some(proj) = all.into_iter().find(|p| p.id == id) {
                let rel = file_path.and_then(|p| compute_relative(&proj, p));
                return Ok((proj, rel));
            }
        }

        // 3b. Session roots
        let session_roots = self.session_roots.read().await;
        for root in session_roots.iter() {
            if let Some((proj, _)) = self.registry.resolve_path(root).await {
                let rel = file_path.and_then(|p| compute_relative(&proj, p));
                return Ok((proj, rel));
            }
        }

        // 3c. If only 1 project exists in the registry, fall back to it
        let all = self.registry.list_projects().await;
        if all.len() == 1 {
            let proj = all[0].clone();
            let rel = file_path.and_then(|p| compute_relative(&proj, p));
            return Ok((proj, rel));
        }

        anyhow::bail!(
            "Could not resolve project context: file_path={:?}, project={:?}. No matching project found in registry.",
            file_path,
            project_param
        )
    }
}

fn compute_relative(project: &Project, file_path: &Path) -> Option<PathBuf> {
    let canonical_file = dunce::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
    let canonical_root = dunce::canonicalize(&project.root_path)
        .unwrap_or_else(|_| PathBuf::from(&project.root_path));

    canonical_file
        .strip_prefix(&canonical_root)
        .ok()
        .map(|p| p.to_path_buf())
}