use std::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::index_gate::{self, CallIndex};
use crate::session::ResponseFormat;
use crate::session::{SessionOptions, ToolProfile};
use crate::telemetry::TokenSavingsMiddleware;
use crate::tools::{
admin, adr, cli_hint, dependency, graph, inspect, navigation, search, tool_definitions_for,
};
#[derive(Clone)]
pub struct ScryerServerState {
pub db: ScryerDb,
pub(crate) health: Arc<std::sync::Mutex<std::collections::HashMap<u64, (Instant, IndexFacts)>>>,
pub engine: EngineService,
pub context: ProjectContextResolver,
pub telemetry: TokenSavingsMiddleware,
pub session_id: String,
}
#[derive(Clone)]
pub struct ScryerMcpServer {
state: ScryerServerState,
options: SessionOptions,
first_query_wait: std::time::Duration,
}
const RECALL_GATE_PASSED: bool = true;
const HEALTH_TTL: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Debug, Clone)]
pub(crate) struct IndexFacts {
parse_failures: usize,
line: String,
}
fn annotate_references(
res: &mut navigation::FindReferencesResult,
facts: Option<&IndexFacts>,
partial: bool,
) {
let Some(facts) = facts else { return };
res.next.index_state = Some(facts.line.clone());
if facts.parse_failures > 0 {
res.notes.push(format!(
"CAVEAT: {} file(s) failed to parse and are not indexed; usages in them are missing",
facts.parse_failures
));
}
if RECALL_GATE_PASSED
&& !partial
&& facts.parse_failures == 0
&& res.all_rust
&& res.total_references > 0
&& !res.ambiguous
&& !res.has_more
&& res.next.complete.is_none()
{
res.next.complete = Some(true);
}
}
fn emit<P: crate::tools::budget::Paged>(
res: P,
format: ResponseFormat,
touched: &mut std::collections::BTreeSet<String>,
) -> anyhow::Result<Value> {
Ok(match format {
ResponseFormat::Text => {
index_gate::collect_files(&serde_json::to_value(&res)?, touched);
Value::String(res.render_text())
}
ResponseFormat::Json => serde_json::to_value(res)?,
})
}
fn index_line<T: serde::Serialize>(
call: &CallIndex,
facts: Option<&IndexFacts>,
res: &T,
) -> Option<String> {
let mut touched = std::collections::BTreeSet::new();
if matches!(call.state, scryer_engine::IndexState::Stale { .. })
&& let Ok(value) = serde_json::to_value(res)
{
index_gate::collect_files(&value, &mut touched);
}
call.header_line(facts.map(|f| f.line.as_str()), &touched)
}
fn is_partial<T: serde::Serialize>(call: &CallIndex, res: &T) -> bool {
let mut touched = std::collections::BTreeSet::new();
if matches!(call.state, scryer_engine::IndexState::Stale { .. })
&& let Ok(value) = serde_json::to_value(res)
{
index_gate::collect_files(&value, &mut touched);
}
call.caveat(&touched).is_some()
}
fn answer<T: serde::Serialize>(
res: T,
format: ResponseFormat,
call: &CallIndex,
touched: &mut std::collections::BTreeSet<String>,
text: impl FnOnce(&T, Option<String>) -> String,
) -> anyhow::Result<Value> {
Ok(match format {
ResponseFormat::Text => {
let value = serde_json::to_value(&res)?;
index_gate::collect_files(&value, touched);
let state = call.header_line(None, touched);
Value::String(text(&res, state))
}
ResponseFormat::Json => serde_json::to_value(res)?,
})
}
pub(crate) fn render_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
}
impl ScryerMcpServer {
fn fit_list<P: crate::tools::budget::Paged>(
&self,
res: &mut P,
max_tokens: Option<usize>,
no_truncate: bool,
format: ResponseFormat,
) {
crate::tools::budget::fit_list_to_budget(res, max_tokens, no_truncate, format, |s| {
self.state.telemetry.count_tokens(s)
});
}
async fn index_facts(&self, project: Option<&str>) -> Option<IndexFacts> {
let (project, _) = self
.state
.context
.resolve_project(None, project)
.await
.ok()?;
if let Ok(cache) = self.state.health.lock()
&& let Some((at, facts)) = cache.get(&project.id)
&& at.elapsed() < HEALTH_TTL
{
return Some(facts.clone());
}
let health = scryer_engine::index_health(&self.state.db, project.id)
.await
.ok()?;
let parse_failures = health.stats.as_ref().map_or(0, |s| s.parse_failures.count);
let indexed = health
.last_indexed
.as_deref()
.unwrap_or("at an unknown time");
let facts = IndexFacts {
parse_failures,
line: format!(
"index: {} files, {} parse failures, indexed {indexed}",
health.file_count, parse_failures
),
};
if let Ok(mut cache) = self.state.health.lock() {
cache.insert(project.id, (Instant::now(), facts.clone()));
}
Some(facts)
}
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,
health: Arc::default(),
engine,
context,
telemetry,
session_id,
},
options: SessionOptions::default(),
first_query_wait: index_gate::first_query_wait(),
})
}
pub fn with_first_query_wait(mut self, wait: std::time::Duration) -> Self {
self.first_query_wait = wait;
self
}
pub fn with_options(mut self, options: SessionOptions) -> Self {
self.options = options;
self
}
pub async fn apply_session_options(&self) -> anyhow::Result<()> {
if let Some(project) = self.options.project.as_deref() {
let (project, _) = self
.state
.context
.resolve_project(None, Some(project.trim()))
.await?;
self.state.context.set_active_project(project.id).await;
}
Ok(())
}
pub fn state(&self) -> &ScryerServerState {
&self.state
}
pub async fn set_session_cwd(&self, cwd: PathBuf) {
self.state.context.set_session_cwd(cwd).await;
}
#[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 project_id = None;
let max_tokens = arguments
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.or(self.options.max_tokens);
let format = arguments
.get("format")
.and_then(|v| serde_json::from_value::<ResponseFormat>(v.clone()).ok())
.unwrap_or(self.options.format);
let project_arg = arguments
.get("project")
.and_then(Value::as_str)
.map(str::to_string);
let asked = arguments
.get("lines")
.or_else(|| arguments.get("at"))
.or_else(|| arguments.get("symbols"))
.and_then(Value::as_array)
.map_or(1, Vec::len);
let max_tokens =
Some(max_tokens.unwrap_or_else(|| crate::tools::budget::default_budget(name, asked)));
let no_truncate = arguments
.get("no_truncate")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut call = if index_gate::needs_index(name) {
index_gate::prepare_call(
&self.state.context,
&self.state.engine,
&arguments,
project_arg.as_deref(),
self.first_query_wait,
)
.await
} else {
CallIndex::none()
};
let mut touched = std::collections::BTreeSet::new();
let mut next_arguments = Some(arguments);
let mut answered_again = false;
let mut dispatch_result: anyhow::Result<Value>;
loop {
let arguments = next_arguments
.take()
.expect("the arguments are set before every pass");
let kept_arguments = arguments.clone();
touched.clear();
dispatch_result = 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?;
if let Ok(mut cache) = self.state.health.lock() {
cache.clear();
}
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.engine,
&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)?;
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)?;
let res = navigation::handle_find_references(
&self.state.context,
&self.state.engine,
params,
)
.await?;
let mut res = res;
let facts = self.index_facts(project_arg.as_deref()).await;
let partial = is_partial(&call, &res);
annotate_references(&mut res, facts.as_ref(), partial);
if let Some(line) = index_line(&call, facts.as_ref(), &res) {
res.next.index_state = Some(line);
}
if partial {
CallIndex::scrub_notes(&mut res.notes);
}
self.fit_list(&mut res, max_tokens, no_truncate, format);
emit(res, format, &mut touched)
}
"get_enclosing_scope" => {
let params: navigation::GetEnclosingScopeParams =
serde_json::from_value(arguments)?;
let res = navigation::handle_get_enclosing_scope(
&self.state.context,
&self.state.engine,
params,
)
.await?;
let mut res = res;
navigation::fit_scope_to_budget(&mut res, max_tokens, no_truncate, |s| {
self.state.telemetry.count_tokens(s)
});
if is_partial(&call, &res) {
CallIndex::scrub_notes(&mut res.notes);
}
answer(
res,
format,
&call,
&mut touched,
crate::tools::render::enclosing_scope,
)
}
"get_file_outline" => {
let params: navigation::GetFileOutlineParams =
serde_json::from_value(arguments)?;
let res = navigation::handle_get_file_outline(
&self.state.context,
&self.state.engine,
params,
)
.await?;
let mut res = res;
let facts = self.index_facts(project_arg.as_deref()).await;
res.next.index_state = index_line(&call, facts.as_ref(), &res);
if is_partial(&call, &res) {
CallIndex::scrub_notes(&mut res.notes);
}
navigation::fit_outline_detail(
&mut res,
max_tokens,
no_truncate,
format,
|s| self.state.telemetry.count_tokens(s),
);
self.fit_list(&mut res, max_tokens, no_truncate, format);
emit(res, format, &mut touched)
}
"get_type_contract" => {
let params: navigation::GetTypeContractParams =
serde_json::from_value(arguments)?;
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?;
let mut res = res;
let facts = self.index_facts(project_arg.as_deref()).await;
res.next.index_state = index_line(&call, facts.as_ref(), &res);
if is_partial(&call, &res) {
CallIndex::scrub_notes(&mut res.notes);
}
self.fit_list(&mut res, max_tokens, no_truncate, format);
emit(res, format, &mut touched)
}
"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?;
let mut res = res;
if is_partial(&call, &res) {
CallIndex::scrub_notes(&mut res.notes);
}
answer(
res,
format,
&call,
&mut touched,
crate::tools::render::blast_radius,
)
}
"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?;
let mut res = res;
self.fit_list(&mut res, max_tokens, no_truncate, ResponseFormat::Json);
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),
);
if format == ResponseFormat::Text {
return Ok(Value::String(crate::tools::render::crate_outline(&res)));
}
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?;
let mut res = res;
let facts = self.index_facts(project_arg.as_deref()).await;
res.next.index_state = index_line(&call, facts.as_ref(), &res);
self.fit_list(&mut res, max_tokens, no_truncate, format);
emit(res, format, &mut touched)
}
"inspect_symbol" => {
let params: inspect::InspectSymbolParams =
serde_json::from_value(arguments)?;
if params.symbols.is_some() {
let res = inspect::handle_inspect_batch(
&self.state.context,
&self.state.engine,
params,
max_tokens,
no_truncate,
|s| self.state.telemetry.count_tokens(s),
)
.await?;
return Ok(serde_json::to_value(res)?);
}
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)
});
if self.options.tools == ToolProfile::Core {
for hint in &mut res.see_also {
*hint = cli_hint(hint);
}
}
if is_partial(&call, &res) {
CallIndex::scrub_notes(&mut res.notes);
}
answer(
res,
format,
&call,
&mut touched,
crate::tools::render::inspect_symbol,
)
}
unknown => {
anyhow::bail!("Unknown tool: '{unknown}'")
}
}
}
.await;
if answered_again || !index_gate::needs_index(name) || dispatch_result.is_err() {
break;
}
if let Ok(value @ (Value::Object(_) | Value::Array(_))) = &dispatch_result {
index_gate::collect_files(value, &mut touched);
}
if let Some(root) = call.root() {
let rels: Vec<PathBuf> = touched
.iter()
.filter_map(|f| index_gate::relative_to_root(&root, f))
.collect();
if index_gate::refresh(&self.state.engine, &mut call, &rels).await {
if let Ok(mut cache) = self.state.health.lock() {
cache.clear();
}
answered_again = true;
next_arguments = Some(kept_arguments);
continue;
}
}
break;
}
if index_gate::needs_index(name)
&& let Ok(value) = &mut dispatch_result
{
call.annotate_json(value, &touched);
}
match dispatch_result {
Ok(val) => {
let json_str = match &val {
Value::String(text) => text.clone(),
other => render_json(other),
};
let (limited_payload, payload_tokens, hard_cut) = self
.state
.telemetry
.enforce_limit(&json_str, max_tokens, no_truncate);
let truncated = hard_cut
|| match &val {
Value::String(text) => text
.lines()
.next()
.is_some_and(|header| header.contains("TRUNCATED")),
other => other.pointer("/next/next_offset").is_some(),
};
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,
truncated,
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(self.options.tools))
}
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: tool_definitions_for(self.options.tools, self.options.always_load),
..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_FULL: &str = r#"Use Scryer instead of grep for code questions: who calls X, where X is defined, what a file contains, what contains this line, what breaks if X changes. Parsed code (Rust, Python, TypeScript), exact lines.
Not for: a literal string, log or error message, comment, config or non-code file, one small file, a tiny repository. Use grep or Read.
Pick a tool by what you know:
- Callers, usages, impact of X: find_references(X) first. view callers: each caller once, with its line; files: the files to change.
- Know the name: inspect_symbol(symbol, or symbols: up to 5) gives definition, signature, snippet, callers, ADRs. Qualify it (Config::new) or pass file_path if ambiguous.
- Don't know the name: search_symbols(query: "retry backoff").
- Know a location (stack trace, diff, grep hits): get_enclosing_scope(file_path, line or lines; or at: ["path:line", ..]) names the containing function; resolve_definition(file_path, line, col) jumps to a definition.
- Outline, signatures, structure: get_file_outline(file_path) before reading a file; get_crate_outline(crate_name); get_type_contract(type_name).
- About to change code: calculate_blast_radius(symbol or file_path); query_adrs for governing decisions; trace_call_hierarchy for multi-hop.
Reading answers:
- Lines are 1-based, columns 0-based. file_path is project-relative or absolute.
- Tools use the working directory's project (list_projects, switch_active_project). The index follows your edits (index_workspace rebuilds it); a first call may wait while it is built.
- complete means exhaustive over indexed files: don't re-grep it unless a CAVEAT applies. INDEXING or STALE means partial: retry shortly. notes explain empty results.
- Not seen: build-script output, other languages. Trait-object and closure calls resolve by name."#;
pub const SERVER_INSTRUCTIONS_CORE: &str = r#"Use Scryer instead of grep for code questions: who calls X, where X is defined, what a file contains, what contains this line, what breaks if X changes. Parsed code (Rust, Python, TypeScript), exact lines.
Not for: a literal string, log or error message, comment, config or non-code file, one small file, a tiny repository. Use grep or Read.
Pick a tool by what you know:
- Callers, usages, impact of X: find_references(X) first. view callers: each caller once, with its line; files: the files to change.
- Know the name: inspect_symbol(symbol, or symbols: up to 5) gives definition, signature, snippet, callers, ADRs. Qualify it (Config::new) or pass file_path if ambiguous.
- Don't know the name: search_symbols(query: "retry backoff").
- Know a location (stack trace, diff, grep hits): get_enclosing_scope(file_path, line or lines; or at: ["path:line", ..]) names the containing function and its code.
- Outline, signatures: get_file_outline(file_path) before reading a file.
- About to change code: calculate_blast_radius(symbol or file_path) for what depends on it.
Call graphs, type contracts, ADRs and project admin are not listed here: run `scryer --help` for the CLI, or start the server with `--tools full`.
Reading answers:
- Lines are 1-based, columns 0-based. file_path is project-relative or absolute.
- Tools use the working directory's project. The index follows your edits; a first call may wait while it is built.
- complete means exhaustive over indexed files: don't re-grep it unless a CAVEAT applies. INDEXING or STALE means partial: retry shortly. notes explain empty results.
- Not seen: build-script output, other languages. Trait-object and closure calls resolve by name."#;
pub fn server_instructions(profile: ToolProfile) -> &'static str {
match profile {
ToolProfile::Core => SERVER_INSTRUCTIONS_CORE,
ToolProfile::Full => SERVER_INSTRUCTIONS_FULL,
}
}
pub const SERVER_INSTRUCTIONS: &str = SERVER_INSTRUCTIONS_FULL;
#[cfg(test)]
mod instructions_tests {
use super::server_instructions;
use crate::session::{AlwaysLoad, ToolProfile};
fn check_instructions_match_profile(profile: ToolProfile) {
let text = server_instructions(profile);
let tools: Vec<String> = crate::tools::tool_definitions_for(profile, AlwaysLoad::None)
.iter()
.map(|t| t.name.to_string())
.collect();
let all: Vec<String> = crate::all_tool_definitions()
.iter()
.map(|t| t.name.to_string())
.collect();
for word in text.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!(
all.iter().any(|t| t == word),
"{profile} instructions mention unknown tool '{word}'"
);
assert!(
tools.iter().any(|t| t == word),
"{profile} instructions mention '{word}', which the profile does not list"
);
}
}
let optional = [
"switch_active_project",
"delete_project",
"get_indexing_status",
"get_token_savings_metrics",
"record_adr",
"delete_adr",
];
for tool in &tools {
assert!(
text.contains(tool.as_str()) || optional.contains(&tool.as_str()),
"{profile} tool '{tool}' is not reachable from the server instructions"
);
}
assert!(
text.len() <= 1_800,
"{profile} instructions are {} chars",
text.len()
);
}
#[test]
fn full_instructions_name_exactly_the_real_tools() {
check_instructions_match_profile(ToolProfile::Full);
}
#[test]
fn core_instructions_name_only_the_core_tools() {
check_instructions_match_profile(ToolProfile::Core);
}
}