use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use rmcp::model::{Tool, ToolAnnotations};
use scryer_db::{DependencyPackage, Project, SourceFile, Symbol};
use scryer_engine::IndexReport;
use crate::context::ProjectContextResolver;
use crate::telemetry::{TokenSavingsMiddleware, TokenSavingsSummary};
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct ListProjectsParams {
pub query: Option<String>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ProjectSummary {
pub id: u64,
pub slug: String,
pub root_path: String,
pub is_active: bool,
pub file_count: usize,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SwitchActiveProjectParams {
pub project: String,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SwitchActiveProjectResult {
pub success: bool,
pub message: String,
pub active_project_id: u64,
pub active_project_slug: String,
pub root_path: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct DeleteProjectParams {
pub project: String,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DeleteProjectResult {
pub success: bool,
pub message: String,
pub project_id: u64,
pub slug: String,
pub root_path: String,
pub deleted_files: usize,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct IndexWorkspaceParams {
pub path: Option<String>,
pub project: Option<String>,
pub full: Option<bool>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct GetIndexingStatusParams {
pub project: Option<String>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IndexingStatusResult {
pub project_id: u64,
pub slug: String,
pub root_path: String,
pub file_count: usize,
pub symbol_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub crate_count: Option<usize>,
pub is_indexing: bool,
pub server: crate::build_info::ServerBuild,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct GetTokenSavingsMetricsParams {
pub session_id: Option<String>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
pub async fn handle_list_projects(
context: &ProjectContextResolver,
db: &scryer_db::ScryerDb,
params: ListProjectsParams,
) -> anyhow::Result<Vec<ProjectSummary>> {
let projects = context.registry().list_projects().await;
let active_id = context
.resolve_project(None, None)
.await
.ok()
.map(|(p, _)| p.id);
let mut summaries = Vec::new();
let mut guard = db.lock().await;
let updated_at: HashMap<u64, String> = Project::all()
.exec(&mut *guard)
.await?
.into_iter()
.map(|p| (p.id, p.updated_at))
.collect();
for p in projects {
let files = SourceFile::filter(SourceFile::fields().project_id().eq(p.id))
.exec(&mut *guard)
.await?;
summaries.push(ProjectSummary {
id: p.id,
slug: p.slug,
root_path: p.root_path,
is_active: Some(p.id) == active_id,
file_count: files.len(),
created_at: p.created_at,
updated_at: updated_at.get(&p.id).cloned().unwrap_or(p.updated_at),
});
}
if let Some(q) = ¶ms.query {
let q_lower = q.to_lowercase();
summaries.retain(|s| {
s.slug.to_lowercase().contains(&q_lower)
|| s.root_path.to_lowercase().contains(&q_lower)
});
}
let offset = params.offset.unwrap_or(0);
let paginated = if offset >= summaries.len() {
Vec::new()
} else {
let remaining = &summaries[offset..];
if let Some(limit) = params.limit {
remaining.iter().take(limit).cloned().collect()
} else {
remaining.to_vec()
}
};
Ok(paginated)
}
pub async fn handle_switch_active_project(
context: &ProjectContextResolver,
params: SwitchActiveProjectParams,
) -> anyhow::Result<SwitchActiveProjectResult> {
let target = params.project.trim();
anyhow::ensure!(
!target.is_empty(),
"`project` must be a project slug, ID or root path (see list_projects)"
);
let (project, _) = context.resolve_project(None, Some(target)).await?;
context.set_active_project(project.id).await;
Ok(SwitchActiveProjectResult {
success: true,
message: format!("Switched active project context to '{}'", project.slug),
active_project_id: project.id,
active_project_slug: project.slug,
root_path: project.root_path,
})
}
pub async fn handle_delete_project(
context: &ProjectContextResolver,
engine: &scryer_engine::EngineService,
params: DeleteProjectParams,
) -> anyhow::Result<DeleteProjectResult> {
let target = context.absolutize(Path::new(params.project.trim())).await;
let registry = context.registry();
let project = match registry.find(¶ms.project).await {
Some(project) => project,
None => registry
.find(&target.to_string_lossy())
.await
.ok_or_else(|| {
anyhow::anyhow!(
"No registered project matches '{}'. Use list_projects to see slugs, IDs, and root paths.",
params.project.trim()
)
})?,
};
let deleted_files = {
let mut guard = engine.db().lock().await;
SourceFile::filter(SourceFile::fields().project_id().eq(project.id))
.exec(&mut *guard)
.await?
.len()
};
registry.remove(project.id).await?;
engine.purge_project(project.id).await?;
context.clear_active_project(project.id).await;
Ok(DeleteProjectResult {
success: true,
message: format!(
"Deleted project '{}' and its index data; files on disk were not touched",
project.slug
),
project_id: project.id,
slug: project.slug,
root_path: project.root_path,
deleted_files,
})
}
pub async fn handle_index_workspace(
context: &ProjectContextResolver,
engine: &scryer_engine::EngineService,
params: IndexWorkspaceParams,
) -> anyhow::Result<IndexReport> {
let target_path = match params.path.as_deref() {
Some(path) => Some(context.absolutize(Path::new(path)).await),
None => None,
};
let project = match target_path {
Some(path) => {
anyhow::ensure!(
path.exists(),
"Cannot index '{}': the path does not exist",
path.display()
);
anyhow::ensure!(
path.is_dir(),
"Cannot index '{}': it is not a directory; pass the workspace root",
path.display()
);
if scryer_db::is_too_broad_root(&path) {
anyhow::bail!(
"Refusing to index '{}': it is a filesystem root or the home directory. Pass a project directory.",
path.display()
);
}
context
.registry()
.register_or_update(&path, params.project.as_deref())
.await?
}
None => {
let (proj, _) = context
.resolve_project(None, params.project.as_deref())
.await?;
proj
}
};
let report = engine
.index_project(project.id, Path::new(&project.root_path))
.await?;
Ok(report)
}
pub async fn handle_get_indexing_status(
context: &ProjectContextResolver,
db: &scryer_db::ScryerDb,
params: GetIndexingStatusParams,
) -> anyhow::Result<IndexingStatusResult> {
if let Some(target) = params.project.as_deref() {
let trimmed = target.trim();
if trimmed == "0"
|| trimmed.eq_ignore_ascii_case("dependencies")
|| trimmed.eq_ignore_ascii_case("cargo")
|| trimmed.eq_ignore_ascii_case("cargo-dependencies")
{
let mut guard = db.lock().await;
let files = SourceFile::filter(SourceFile::fields().project_id().eq(0))
.exec(&mut *guard)
.await?;
let symbols = Symbol::filter(Symbol::fields().project_id().eq(0))
.exec(&mut *guard)
.await?;
let packages = DependencyPackage::all().exec(&mut *guard).await?;
let cargo_home = std::env::var("CARGO_HOME")
.or_else(|_| std::env::var("HOME").map(|h| format!("{h}/.cargo")))
.unwrap_or_else(|_| "~/.cargo".to_string());
return Ok(IndexingStatusResult {
project_id: 0,
slug: "cargo-dependencies".to_string(),
root_path: cargo_home,
file_count: files.len(),
symbol_count: symbols.len(),
crate_count: Some(packages.len()),
is_indexing: false,
server: crate::build_info::ServerBuild::current(),
});
}
}
let (project, _) = context
.resolve_project(None, params.project.as_deref())
.await?;
let mut guard = db.lock().await;
let files = SourceFile::filter(SourceFile::fields().project_id().eq(project.id))
.exec(&mut *guard)
.await?;
let symbols = Symbol::filter(Symbol::fields().project_id().eq(project.id))
.exec(&mut *guard)
.await?;
Ok(IndexingStatusResult {
project_id: project.id,
slug: project.slug,
root_path: project.root_path,
file_count: files.len(),
symbol_count: symbols.len(),
crate_count: None,
is_indexing: false,
server: crate::build_info::ServerBuild::current(),
})
}
pub async fn handle_get_token_savings_metrics(
telemetry: &TokenSavingsMiddleware,
params: GetTokenSavingsMetricsParams,
) -> anyhow::Result<TokenSavingsSummary> {
telemetry.get_metrics(params.session_id.as_deref()).await
}
pub fn tool_definitions() -> Vec<Tool> {
vec![
make_tool::<ListProjectsParams>(
"list_projects",
"Use to see registered projects or confirm which one is active before project-scoped queries. Returns each project's slug, root, file count and timestamps.",
read_only(),
),
make_tool::<SwitchActiveProjectParams>(
"switch_active_project",
"Use when later calls without an explicit project should resolve against a different registered project. Changes this session's active project; does not touch the index. An error if no registered project matches.",
mutating(true),
),
make_tool::<DeleteProjectParams>(
"delete_project",
"Use only when project removal is explicitly intended. Unregisters the project and deletes its index data and ADR database records; source files and ADR Markdown on disk remain untouched.",
destructive(),
),
make_tool::<IndexWorkspaceParams>(
"index_workspace",
"Use to create or refresh Scryer's code index when a workspace is new, missing, or stale (e.g. results reference lines that moved). Scans incrementally by content hash by default; set full to force a complete scan. Pass `path` (a project directory) to register a new workspace; omit it to refresh the active project. An error if the path does not exist or is a filesystem root or the home directory.",
mutating(true),
),
make_tool::<GetIndexingStatusParams>(
"get_indexing_status",
"Use to check whether indexing is running or queued, or how many files are indexed, before trusting query results. Pass project: \"0\" or \"dependencies\" to inspect the global Cargo dependency cache.",
read_only(),
),
make_tool::<GetTokenSavingsMetricsParams>(
"get_token_savings_metrics",
"Use when reviewing Scryer tool usage or its token and latency impact. Aggregates invocations per tool, tokens saved versus reading files, query latencies and estimated cost reduction; optionally filter by session.",
read_only(),
),
]
}
pub(crate) fn read_only() -> ToolAnnotations {
ToolAnnotations::new()
.read_only(true)
.destructive(false)
.idempotent(true)
.open_world(false)
}
pub(crate) fn mutating(idempotent: bool) -> ToolAnnotations {
ToolAnnotations::new()
.read_only(false)
.destructive(false)
.idempotent(idempotent)
.open_world(false)
}
pub(crate) fn destructive() -> ToolAnnotations {
ToolAnnotations::new()
.read_only(false)
.destructive(true)
.idempotent(true)
.open_world(false)
}
pub(crate) fn make_tool<T: JsonSchema>(
name: &'static str,
description: &'static str,
annotations: ToolAnnotations,
) -> Tool {
let schema = schemars::schema_for!(T);
let val = serde_json::to_value(schema).unwrap_or(Value::Object(Default::default()));
let mut obj = match val {
Value::Object(map) => map,
_ => Default::default(),
};
super::schema::simplify_schema(name, &mut obj);
let mut tool = Tool::default();
tool.name = name.into();
tool.title = Some(name.to_string());
tool.description = Some(description.into());
tool.input_schema = Arc::new(obj);
tool.annotations = Some(annotations);
tool
}