sinter-io 0.52.2

sinter command-line interface
//! `sinter serve`: MCP transport and session orchestration over stdio.
//!
//! The server owns newline-delimited JSON-RPC framing, request routing, and
//! repository freshness. Tool contracts and execution live in their semantic
//! modules; the hand-rolled protocol subset remains intentionally small.

use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};

use anyhow::Result;
use serde_json::{Value, json};

enum Scope {
    Repo {
        repo: PathBuf,
        freshness: crate::freshness::RepoFreshness,
    },
    Workspace(PathBuf),
}

pub fn run(repo: &Path) -> Result<()> {
    let repo = repo.canonicalize()?;
    let freshness = crate::freshness::RepoFreshness::new(&repo)?;
    serve(Scope::Repo { repo, freshness })
}

pub fn run_workspace(manifest: &Path) -> Result<()> {
    serve(Scope::Workspace(manifest.canonicalize()?))
}

fn serve(scope: Scope) -> Result<()> {
    let stdin = std::io::stdin();
    let mut stdout = std::io::stdout().lock();
    // The repository-wide coverage half already sent in this session. One
    // process serves many calls against one graph state; repeating it is
    // the largest fixed cost in a small answer.
    let mut coverage_ref: Option<String> = None;
    for line in stdin.lock().lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        let Ok(request): Result<Value, _> = serde_json::from_str(&line) else {
            writeln!(
                stdout,
                "{}",
                json!({"jsonrpc": "2.0", "id": Value::Null,
                       "error": {"code": -32700, "message": "parse error"}})
            )?;
            stdout.flush()?;
            continue;
        };
        let id = request.get("id").cloned();
        let method = request.get("method").and_then(Value::as_str).unwrap_or("");
        let params = request.get("params").cloned().unwrap_or(Value::Null);
        let Some(id) = id else {
            continue;
        };
        let response = match handle(&scope, method, &params, &mut coverage_ref) {
            Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
            Err(error) => error_response(id, method, &params, &error),
        };
        writeln!(stdout, "{response}")?;
        stdout.flush()?;
    }
    Ok(())
}

fn error_response(id: Value, method: &str, params: &Value, error: &anyhow::Error) -> Value {
    let mut message = format!("{error:#}");
    if message.contains("no symbol matches") {
        if let Some(position) = message.find(" — try `sinter ask") {
            message.truncate(position);
        }
        message.push_str(" — try the ask tool for concept search");
    }
    let operation = if method == "tools/call" {
        params
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or("unknown")
    } else {
        method
    };
    let mut data = crate::agent_protocol::failure(operation, error);
    data["error"]["message"] = json!(message);
    json!({
        "jsonrpc": "2.0",
        "id": id,
        "error": {"code": -32000, "message": message, "data": data}
    })
}

fn handle(
    scope: &Scope,
    method: &str,
    params: &Value,
    coverage_ref: &mut Option<String>,
) -> Result<Value> {
    match method {
        "initialize" => Ok(json!({
            "protocolVersion": params
                .get("protocolVersion")
                .and_then(Value::as_str)
                .unwrap_or("2024-11-05"),
            "capabilities": {"tools": {}, "resources": {}},
            "serverInfo": {"name": "sinter", "version": env!("CARGO_PKG_VERSION")},
            "instructions": format!(
                "sinter answers code-structure questions from a dependency graph. \
                 Read `structuredContent` (content text is a summary only) and check \
                 `outcome.status` before acting. Key legend, coverage semantics, \
                 batching, and budget paging: see resource {}",
                crate::tool_catalog::GUIDE_URI
            ),
        })),
        "ping" => Ok(json!({})),
        "resources/list" => {
            let mut resources = vec![json!({
                "uri": crate::tool_catalog::GUIDE_URI,
                "name": "guide",
                "description": "How to read sinter results: key legend, coverage, batching, paging",
                "mimeType": "text/markdown",
            })];
            if matches!(scope, Scope::Repo { .. }) {
                resources.push(json!({
                    "uri": crate::coverage::COVERAGE_URI,
                    "name": "coverage",
                    "description":
                        "Repository-wide coverage: what a collapsed `coverage.ref` in a tool result names",
                    "mimeType": "application/json",
                }));
            }
            Ok(json!({"resources": resources}))
        }
        "resources/templates/list" => Ok(json!({"resourceTemplates": []})),
        "resources/read" => {
            let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
            match (uri, scope) {
                (crate::tool_catalog::GUIDE_URI, _) => Ok(json!({"contents": [{
                    "uri": uri,
                    "mimeType": "text/markdown",
                    "text": crate::tool_catalog::GUIDE,
                }]})),
                (crate::coverage::COVERAGE_URI, Scope::Repo { repo, freshness }) => {
                    freshness.sync()?;
                    let store = crate::lookup::open_current(repo)?;
                    let coverage = crate::coverage::shared_document(repo, &store)?;
                    Ok(json!({"contents": [{
                        "uri": uri,
                        "mimeType": "application/json",
                        "text": serde_json::to_string(&coverage)?,
                    }]}))
                }
                _ => anyhow::bail!("unknown resource {uri}"),
            }
        }
        "tools/list" => Ok(match scope {
            Scope::Repo { .. } => crate::tool_catalog::repository(),
            Scope::Workspace(_) => crate::tool_catalog::workspace(),
        }),
        "tools/call" => call_tool(scope, params, coverage_ref),
        other => anyhow::bail!("unknown method {other}"),
    }
}

fn call_tool(scope: &Scope, params: &Value, coverage_ref: &mut Option<String>) -> Result<Value> {
    let name = params.get("name").and_then(Value::as_str).unwrap_or("");
    let mut args = params
        .get("arguments")
        .cloned()
        .unwrap_or_else(|| json!({}));
    let budget = crate::agent_protocol::take_budget(&mut args)?;
    crate::agent_protocol::validate_arguments(name, &args, matches!(scope, Scope::Workspace(_)))?;

    // A session-lived store would hold redb's exclusive lock across calls,
    // blocking graph refresh and pinning a stale snapshot.
    let mut result = match scope {
        Scope::Repo { repo, freshness } => {
            freshness.sync()?;
            crate::repository_tools::call(repo, name, &args)?
        }
        Scope::Workspace(manifest) => crate::workspace_tools::call(manifest, name, &args)?,
    };
    let carried = crate::coverage::collapse_repeated(&mut result, coverage_ref.as_deref());
    if carried.is_some() {
        *coverage_ref = carried;
    }
    crate::agent_protocol::mcp_success(name, &result, budget)
}