use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use rmcp::{
ErrorData as McpError, Peer, RoleServer, ServerHandler,
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
InitializeRequestParams, InitializeResult, ListToolsResult, PaginatedRequestParams,
ServerCapabilities, ServerConfig,
},
service::{NotificationContext, RequestContext},
};
use scryer_db::{ProjectRegistry, ScryerDb};
use scryer_engine::EngineService;
use serde_json::Value;
use crate::context::ProjectContextResolver;
use crate::telemetry::{TokenSavingsMiddleware, ToolCategory};
use crate::tools::{
admin, adr, all_tool_definitions, dependency, graph, inspect, navigation, search,
};
#[derive(Clone)]
pub struct ScryerServerState {
pub db: ScryerDb,
pub engine: EngineService,
pub context: ProjectContextResolver,
pub telemetry: TokenSavingsMiddleware,
pub session_id: String,
}
#[derive(Clone)]
pub struct ScryerMcpServer {
state: ScryerServerState,
}
impl ScryerMcpServer {
pub fn new(
db: ScryerDb,
engine: EngineService,
registry: Arc<ProjectRegistry>,
session_id: Option<String>,
) -> anyhow::Result<Self> {
let telemetry = TokenSavingsMiddleware::new(db.clone())?;
let context = ProjectContextResolver::new(registry);
let session_id = session_id.unwrap_or_else(|| {
format!(
"scryer-sess-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
)
});
Ok(Self {
state: ScryerServerState {
db,
engine,
context,
telemetry,
session_id,
},
})
}
pub fn state(&self) -> &ScryerServerState {
&self.state
}
pub async fn set_session_cwd(&self, cwd: PathBuf) {
self.state.context.set_session_cwd(cwd).await;
}
async fn file_size(&self, file_path: &str) -> Option<usize> {
let path = self.state.context.absolutize(Path::new(file_path)).await;
std::fs::metadata(path).ok().map(|meta| meta.len() as usize)
}
#[allow(deprecated)]
async fn refresh_session_roots(&self, peer: &Peer<RoleServer>) {
let supports_roots = peer
.peer_info()
.is_some_and(|info| info.capabilities.roots.is_some());
if !supports_roots {
return;
}
match peer.list_roots().await {
Ok(result) => {
let roots: Vec<PathBuf> = result
.roots
.iter()
.filter_map(|root| file_uri_to_path(&root.uri))
.collect();
if !roots.is_empty() {
self.state.context.set_session_roots(roots).await;
}
}
Err(e) => tracing::debug!("Client roots/list failed: {e}"),
}
}
pub async fn dispatch_tool(
&self,
name: &str,
mut arguments: Value,
) -> Result<CallToolResponse, McpError> {
let start_time = Instant::now();
let tool_name = name.to_string();
crate::tools::schema::coerce_arguments(name, &mut arguments);
let mut file_bytes = None;
let mut match_count = None;
let mut project_id = None;
let max_tokens = arguments
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let no_truncate = arguments
.get("no_truncate")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let dispatch_result: anyhow::Result<Value> = async {
match name {
"list_projects" => {
let params: admin::ListProjectsParams = serde_json::from_value(arguments)?;
let res =
admin::handle_list_projects(&self.state.context, &self.state.db, params)
.await?;
Ok(serde_json::to_value(res)?)
}
"switch_active_project" => {
let params: admin::SwitchActiveProjectParams =
serde_json::from_value(arguments)?;
let res =
admin::handle_switch_active_project(&self.state.context, params).await?;
project_id = Some(res.active_project_id);
Ok(serde_json::to_value(res)?)
}
"delete_project" => {
let params: admin::DeleteProjectParams = serde_json::from_value(arguments)?;
let res = admin::handle_delete_project(
&self.state.context,
&self.state.engine,
params,
)
.await?;
Ok(serde_json::to_value(res)?)
}
"index_workspace" => {
let params: admin::IndexWorkspaceParams = serde_json::from_value(arguments)?;
let res = admin::handle_index_workspace(
&self.state.context,
&self.state.engine,
params,
)
.await?;
Ok(serde_json::json!({
"scanned_files": res.scanned_files,
"added_files": res.added_files,
"modified_files": res.modified_files,
"unchanged_files": res.unchanged_files,
"deleted_files": res.deleted_files,
"total_scopes": res.total_scopes,
"total_symbols": res.total_symbols,
"total_references": res.total_references,
"total_edges": res.total_edges,
}))
}
"get_indexing_status" => {
let params: admin::GetIndexingStatusParams = serde_json::from_value(arguments)?;
let res = admin::handle_get_indexing_status(
&self.state.context,
&self.state.db,
params,
)
.await?;
project_id = Some(res.project_id);
Ok(serde_json::to_value(res)?)
}
"get_token_savings_metrics" => {
let params: admin::GetTokenSavingsMetricsParams =
serde_json::from_value(arguments)?;
let res =
admin::handle_get_token_savings_metrics(&self.state.telemetry, params)
.await?;
Ok(serde_json::to_value(res)?)
}
"resolve_definition" => {
let params: navigation::ResolveDefinitionParams =
serde_json::from_value(arguments)?;
file_bytes = self.file_size(¶ms.file_path).await;
let res = navigation::handle_resolve_definition(
&self.state.context,
&self.state.engine,
params,
)
.await?;
Ok(serde_json::to_value(res)?)
}
"find_references" => {
let params: navigation::FindReferencesParams =
serde_json::from_value(arguments)?;
if let Some(fp) = ¶ms.file_path {
file_bytes = self.file_size(fp).await;
}
let res = navigation::handle_find_references(
&self.state.context,
&self.state.engine,
params,
)
.await?;
match_count = Some(res.total_references);
Ok(serde_json::to_value(res)?)
}
"get_enclosing_scope" => {
let params: navigation::GetEnclosingScopeParams =
serde_json::from_value(arguments)?;
file_bytes = self.file_size(¶ms.file_path).await;
let res = navigation::handle_get_enclosing_scope(
&self.state.context,
&self.state.engine,
params,
)
.await?;
Ok(serde_json::to_value(res)?)
}
"get_file_outline" => {
let params: navigation::GetFileOutlineParams =
serde_json::from_value(arguments)?;
file_bytes = self.file_size(¶ms.file_path).await;
let res = navigation::handle_get_file_outline(
&self.state.context,
&self.state.engine,
params,
)
.await?;
match_count = Some(res.symbols.len());
Ok(serde_json::to_value(res)?)
}
"get_type_contract" => {
let params: navigation::GetTypeContractParams =
serde_json::from_value(arguments)?;
if let Some(fp) = ¶ms.file_path {
file_bytes = self.file_size(fp).await;
}
let res = navigation::handle_get_type_contract(
&self.state.context,
&self.state.engine,
params,
)
.await?;
Ok(serde_json::to_value(res)?)
}
"trace_call_hierarchy" => {
let params: graph::TraceCallHierarchyParams =
serde_json::from_value(arguments)?;
let res = graph::handle_trace_call_hierarchy(
&self.state.context,
&self.state.engine,
params,
)
.await?;
match_count = Some(res.nodes.len());
Ok(serde_json::to_value(res)?)
}
"calculate_blast_radius" => {
let params: graph::CalculateBlastRadiusParams =
serde_json::from_value(arguments)?;
let res = graph::handle_calculate_blast_radius(
&self.state.context,
&self.state.engine,
params,
)
.await?;
match_count = Some(res.affected_symbols_count);
Ok(serde_json::to_value(res)?)
}
"query_adrs" => {
let params: adr::QueryAdrsParams = serde_json::from_value(arguments)?;
let res =
adr::handle_query_adrs(&self.state.context, &self.state.engine, params)
.await?;
match_count = Some(res.total_matches);
Ok(serde_json::to_value(res)?)
}
"record_adr" => {
let params: adr::RecordAdrParams = serde_json::from_value(arguments)?;
let res =
adr::handle_record_adr(&self.state.context, &self.state.engine, params)
.await?;
Ok(serde_json::to_value(res)?)
}
"delete_adr" => {
let params: adr::DeleteAdrParams = serde_json::from_value(arguments)?;
let res =
adr::handle_delete_adr(&self.state.context, &self.state.engine, params)
.await?;
Ok(serde_json::to_value(res)?)
}
"get_crate_outline" => {
let params: dependency::GetCrateOutlineParams =
serde_json::from_value(arguments)?;
let mut res = dependency::handle_get_crate_outline(
&self.state.context,
&self.state.engine,
params,
)
.await?;
dependency::fit_crate_outline_to_budget(
&mut res,
max_tokens,
no_truncate,
|s| self.state.telemetry.count_tokens(s),
);
match_count = Some(res.modules.len() + res.core_types.len());
Ok(serde_json::to_value(res)?)
}
"search_symbols" => {
let params: search::SearchSymbolsParams = serde_json::from_value(arguments)?;
let res = search::handle_search_symbols(
&self.state.context,
&self.state.engine,
params,
)
.await?;
match_count = Some(res.total);
Ok(serde_json::to_value(res)?)
}
"inspect_symbol" => {
let params: inspect::InspectSymbolParams = serde_json::from_value(arguments)?;
let mut res = inspect::handle_inspect_symbol(
&self.state.context,
&self.state.engine,
params,
)
.await?;
inspect::fit_snippet_to_budget(&mut res, max_tokens, no_truncate, |s| {
self.state.telemetry.count_tokens(s)
});
match_count = res.reference_count;
Ok(serde_json::to_value(res)?)
}
unknown => {
anyhow::bail!("Unknown tool: '{unknown}'")
}
}
}
.await;
match dispatch_result {
Ok(val) => {
let json_str =
serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
let (limited_payload, payload_tokens, _) =
self.state
.telemetry
.enforce_limit(&json_str, max_tokens, no_truncate);
let category = ToolCategory::for_tool(&tool_name);
let naive_tokens =
TokenSavingsMiddleware::calculate_naive_cost(category, file_bytes, match_count);
let active_pid = self.state.context.get_active_project().await;
self.state.telemetry.record_invocation(
self.state.session_id.clone(),
project_id.or(active_pid),
tool_name,
payload_tokens as u64,
naive_tokens,
start_time,
);
Ok(CallToolResult::success(vec![ContentBlock::text(limited_payload)]).into())
}
Err(e) => {
let err_msg = format!("Tool execution error for '{tool_name}': {e}");
Ok(CallToolResult::error(vec![ContentBlock::text(err_msg)]).into())
}
}
}
}
impl ServerHandler for ScryerMcpServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("scryer-mcp", env!("CARGO_PKG_VERSION")))
.with_instructions(SERVER_INSTRUCTIONS)
}
async fn initialize(
&self,
request: InitializeRequestParams,
context: RequestContext<RoleServer>,
) -> Result<InitializeResult, McpError> {
context.peer.set_peer_info(request.clone());
self.negotiate_initialize(&request)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: all_tool_definitions(),
..Default::default()
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let args_value = request
.arguments
.map(Value::Object)
.unwrap_or(Value::Object(Default::default()));
self.dispatch_tool(&request.name, args_value).await
}
async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
self.refresh_session_roots(&context.peer).await;
}
async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) {
self.refresh_session_roots(&context.peer).await;
}
}
fn file_uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let path = &rest[rest.find('/')?..];
let bytes = path.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(&path[i + 1..i + 3], 16)
{
decoded.push(byte);
i += 3;
} else {
decoded.push(bytes[i]);
i += 1;
}
}
let decoded = String::from_utf8(decoded).ok()?;
let trimmed = match decoded.as_bytes() {
[b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &decoded[1..],
_ => decoded.as_str(),
};
Some(PathBuf::from(trimmed))
}
#[cfg(test)]
mod tests {
use super::file_uri_to_path;
use std::path::PathBuf;
#[test]
fn file_uris_decode_to_paths() {
assert_eq!(
file_uri_to_path("file:///home/me/my%20project"),
Some(PathBuf::from("/home/me/my project"))
);
assert_eq!(
file_uri_to_path("file://localhost/srv/repo"),
Some(PathBuf::from("/srv/repo"))
);
assert_eq!(file_uri_to_path("https://example.com/x"), None);
}
}
pub const SERVER_INSTRUCTIONS: &str = r#"Scryer is code intelligence over a parsed index of Rust, Python and TypeScript projects. Prefer it to grep and whole-file reads: answers come from parsed code, with exact line spans.
Pick a tool by what you already know:
- Don't know the name ("where is the retry logic?"): search_symbols(query: "retry backoff"); like_symbol finds code similar to a known symbol.
- Know the name: inspect_symbol(symbol) returns the definition, a snippet, 1-hop callers/callees, a reference count and related ADRs in one call. Qualify the name (Config::new, tokio::sync::Mutex) or pass file_path when it is ambiguous.
- Know a location (stack trace, diff, compiler error): get_enclosing_scope(file_path, line) names the containing function or type; resolve_definition(file_path, line, col) jumps from a reference to its definition.
- Need structure: get_file_outline(file_path) before reading a file; get_crate_outline(crate_name) for a dependency; get_type_contract(type_name) for a type's fields, variants and methods.
- About to change code: calculate_blast_radius(symbol or file_path) for what depends on it, and query_adrs(query) for decisions and invariants that govern it.
- Going deeper: find_references (every usage), trace_call_hierarchy (multi-hop callers or callees).
Conventions:
- Lines are 1-based, columns 0-based. file_path is project-relative or absolute and must lie inside a registered project.
- `project` is optional and defaults to the session's active project. When you pass it, it must be a project's slug, ID or root path; anything else is an error (list_projects shows them).
- Responses are capped at about 1,000 tokens. Page with offset/limit, narrow with filters (kinds, file_filter, role), or raise max_tokens / set no_truncate.
- A response's `notes` explain why it is empty, and `see_also` names the next call to make. An empty result with a note means the name or path didn't match; it does not mean "no usages".
- If results look stale after edits, run index_workspace."#;
#[cfg(test)]
mod instructions_tests {
use super::SERVER_INSTRUCTIONS;
#[test]
fn instructions_name_exactly_the_real_tools() {
let tools: Vec<String> = crate::all_tool_definitions()
.iter()
.map(|t| t.name.to_string())
.collect();
for word in SERVER_INSTRUCTIONS.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) {
let looks_like_tool = word.contains('_')
&& word.chars().all(|c| c.is_ascii_lowercase() || c == '_')
&& [
"search_",
"inspect_",
"get_",
"resolve_",
"calculate_",
"trace_",
"find_",
"query_",
"list_",
"index_",
]
.iter()
.any(|p| word.starts_with(p));
if looks_like_tool {
assert!(
tools.iter().any(|t| t == word),
"instructions mention unknown tool '{word}'"
);
}
}
let optional = [
"switch_active_project",
"delete_project",
"get_indexing_status",
"get_token_savings_metrics",
"record_adr",
"delete_adr",
];
for tool in &tools {
assert!(
SERVER_INSTRUCTIONS.contains(tool.as_str()) || optional.contains(&tool.as_str()),
"tool '{tool}' is not reachable from the server instructions"
);
}
}
}