Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
scryer-mcp
Model Context Protocol (MCP) server implementation for Scryer.
Responsibilities
- Protocol Engine: Implements the official
rmcpv3.5.1ServerHandlerprotocol interface. - 19 Discrete Tools: One tool per question an agent asks, across 7 routers (Administration, Navigation, Dependency Discovery, Symbol Search, Symbol Inspection, Graph Traversal, and Architectural Memory). First-contact tools answer in one call; capped summaries point to deep-dive tools (ADR 0011).
- Tool Annotations: Every tool carries MCP
ToolAnnotations(make_toolinsrc/tools/admin.rs): query tools arereadOnlyHint;index_workspace/switch_active_projectare idempotent writes;record_adrwrites;delete_project/delete_adraredestructiveHint. - 3-Tier Context Cascade: Resolves project multi-tenancy automatically via canonical path prefix matching, explicit arguments, or session workspace roots.
- Token Budget and Usage Telemetry: Measures exact payload token counts using
tiktoken-rs, enforces a per-tool token budget (1,000 by default; see Response Shapes and Budgets) and persists measured usage (tool, response tokens, truncation, latency; never content) toToolInvocationMetricin Turso. No savings estimate is reported (ADR 0015). - Shared Daemon (
src/daemon/, Unix only): One process owns the database and serves many MCP clients over a Unix socket, each with its own session. - Architectural Memory: Parses Markdown frontmatter (
gray_matter) and document ASTs (pulldown-cmark) into a cached corpus, ranked with BM25,affected_pathsglobs (globset) and call-graph proximity.
Architecture
Client (AI Agent / IDE) <---> rmcp ServerHandler (ScryerMcpServer)
|
+--------------------+--------------------+
| |
3-Tier Context Cascade Token Budget & Telemetry
(crates/scryer-mcp/src/context.rs) (crates/scryer-mcp/src/telemetry.rs)
| |
+--------------------+--------------------+
|
Tool Dispatcher
|
+-----------------+------------+-------------+------------+-------------+-----------------+
| | | | | | |
Administration Navigation Dependencies Search Inspection Graph Traversal Architectural Memory
(src/tools/admin) (navigation) (dependency) (search) (inspect) (src/tools/graph)(adr, adr_rank)
- list_projects - resolve_def - get_crate_.. - search_sym.. - inspect_sym. - trace_call_... - query_adrs
- switch_active... - find_refs - calculate_... - record_adr
- delete_project - get_scope - delete_adr
- index_workspace - get_outline
- get_indexing... - get_type_..
- get_token_...
Tool Contract
How the tools behave when an agent calls them slightly wrong. Each point has a test in tests/tool_contract_test.rs.
- Published schemas are portable (
tools/schema.rs,simplify_schema): plaintype(no["string","null"]), inlineenum, no$ref/$defs/format/minimum, because Gemini-style function declarations and strict validators reject those. A unit test fails if any schema regains one. - Closed value sets are enums (
tools/enums.rs):scope_level,role,directionandstatuslist their valid values in the schema, and a wrong value is an error naming the choices.directionalso acceptscallers/callees. max_tokens,no_truncateandprojectare described once inschema.rs, and published only where they matter (BUDGET_PARAM_TOOLS: the five tools with large outputs;PROJECT_PARAM_TOOLS: the five project-admin tools). Publishing and accepting are separate:accepted_tool_definitions()(tools/mod.rs) keeps every parameter and is whatcoerce_argumentsand dispatch use, whileall_tool_definitions()appliespublish_schema. A call that still sends a hidden parameter works (parameters_that_are_not_published_are_still_accepted_and_coerced).- Arguments are coerced to the published types (
coerce_arguments):"12"for an integer,"true"for a boolean, a bare string for an array, andnullfor an absent optional all mean what the agent intended. A string that isn't a number is still an error. - An explicit
projectmust match a registered project's slug, ID or root path (ProjectContextResolver::resolve_project); otherwise the call errors instead of falling back to the active or only project.switch_active_projectandindex_workspace(nonexistent or non-directory path) fail the same way. - Required combinations are enforced with a message:
calculate_blast_radiusneedssymbolorfile_path;search_symbolsexactly one ofquery/like_symbol;delete_adrone ofadr_number/file_path/title;scope_level: "file"needsfile_path. Lines are 1-based;line: 0is an error. - References name their container: each
find_referencesitem carriesenclosing_symbol(the qualified name of the function or type it sits in), so "who calls X" is one call, not oneget_enclosing_scopeper site. - Empty means explained:
find_references,calculate_blast_radius,get_type_contract,get_enclosing_scope,query_adrsandget_file_outlinereturnnoteswhen the name, path or filter matched nothing;resolve_definitionputs the reason innotice;get_crate_outlineexplains informatted_markdown. An unknown name is distinguishable from "no usages". - Server instructions (
server_instructions(profile)inserver.rs, sent atinitialize) carry the which-tool decision tree and these conventions for every client. There is one text per profile; a test per profile fails if it names a tool the profile doesn't list or leaves a listed tool unreachable, and both must stay within 1,800 characters (Claude Code silently truncates at 2,048). - Definitions have a size budget (
the_tool_list_stays_small,every_tool_definition_fits_its_budget): about 13.6k characters in total (21.3k before Plan 17), each description at most 600.cargo test -p scryer-mcp --test tool_contract_test -- --ignored --nocapture print_definition_sizesprints the per-tool figures.
Response Shapes and Budgets (Plan 19, ADR 0014)
Agents lose on turns, not accuracy, so answers are built to need no second call and to say how complete they are.
- Compact JSON, no waste: results are
serde_json::to_string(no pretty-printing), omit nulls and empty arrays, and never carry byte offsets.response_shape_test.rsfails if any default response has anullorstart_byte. - Budgets fit by structure (
tools/budget.rs): list results implementPagedandfit_list_to_budgetkeeps the largest prefix of items that fits (measured in the chosen format, with every field the cut will add). A cut result setscomplete: false,next_offsetand asee_alsonaming the call, so the rest is one call away.telemetry::enforce_limitremains the last resort for free-form output; it now cuts at a line (or, for single-line compact JSON, a comma) and namesmax_tokens: 2×. Outlines shed detail first (navigation::fit_outline_detail); a scope answer shortens its snippet (fit_scope_to_budget). Budgets with no explicitmax_tokens:default_budget(tool, items). find_referencesviews:callers(one entry per enclosing function: first usage, its source line, count),files(distinct files with counts),sites(every usage withtext); exactly three values in one envelope.total_files,total_callersandname_matched(usages tied by name only) describe the whole answer, not just the page.- Same-named definitions are grouped, not merged (
find_references,calculate_blast_radius,trace_call_hierarchy):ambiguous,definitions/candidateswithusages, largest first. - Text format (
format: "text", session default viascryer serve --format text; not published in tool schemas, seeschema.rs):find_references,search_symbols,get_file_outline,trace_call_hierarchyandget_crate_outlinereturn a header line and one line per item (tools/render.rs). The header readstool subject: summary | range | complete / TRUNCATED: call again with offset=N | index: files, parse failures, indexed <time> | CAVEAT ...; other notes follow asnote:lines. JSON is the default until the agent A/B (follow-up rate, argument filling) shows text does not regress. completeis set on afind_referencesanswer only whenRECALL_GATE_PASSED(server.rs; Rust recall at least 98% at 60 identifiers on cargo and this repository), every usage is in a.rsfile, the answer is not cut or ambiguous, and no file failed to parse (otherwise aCAVEATnote names the failures). It means exhaustive over the indexed files, not "every usage in the world".- Measuring it:
cargo run --release -p scryer-mcp --example response_bench -- --db <copy of work/<suite>/db/scryer.db> --project <suite> --probes testing-harness/probes/<suite>.json --baseline testing-harness/results/response-bench/<suite>-baseline.jsonprints chars, tokens, calls and answer hits per probe and exits 1 when an answer is lost or calls rise (docs/learnings/response-bench.md).testing-harness/harness.py metricsgives follow-up and cross-check rates per tool from saved agent transcripts.
Session Options and Tool Profiles (src/session.rs)
SessionOptions { tools, max_tokens, project, always_load, format } is what scryer serve flags (--tools, --max-tokens, --project, --always-load, --format) become. ScryerMcpServer::new keeps default options; use .with_options(..) and then apply_session_options().await (which makes project active, after the cwd is set, and fails on an unknown project).
- Profiles (
ToolProfile):fulllists all 19 tools;coreliststools::CORE_TOOLS(search_symbols,inspect_symbol,find_references,get_file_outline,get_enclosing_scope,calculate_blast_radius). The profile only changestools/list, the instructions andsee_also(which gains(CLI: scryer <command>)for hidden tools);dispatch_toolaccepts every tool name under either profile. See ADR 0012. alwaysLoad: withAlwaysLoad::Core(default) the core tools carry_meta["anthropic/alwaysLoad"] = true, so Claude Code loads them withoutToolSearch. Other clients ignore it.- Budget precedence: a call's
max_tokenswins over the session's--max-tokens, which wins over the tool's default (tools::budget::default_budget: 1,000; 1,500 forfind_referencesandsearch_symbols; 2,000 forget_file_outline; 600 forget_enclosing_scope, more for a batch; 500 per name for a batchedinspect_symbol). Only the response-size budget uses the session value; the outline docstring rule ("full docs whenmax_tokensis passed") still keys on the explicit argument.
Index State, First Use and Freshness (src/index_gate.rs, Plan 20)
A tool call never answers from an index that is half built, and sees the caller's own edits. EngineService keeps an IndexState per project (scryer-engine/src/index_state.rs): Ready, Building { done, total } (the first index of a project; shared by every session, the watcher and CLI runs) or Stale { pending } (a re-index over an existing index, listing the files it has not updated). index_project sets it under the per-project write guard and clears it when the run ends, however it ends.
dispatch_tool wraps every tool that reads the index (index_gate::needs_index):
- Resolve and register. If no project matches and the session's working directory is a project root, it is registered and indexed in the background first.
- Wait. A call that arrives while the first index runs waits for it, up to
SCRYER_FIRST_QUERY_WAITseconds (default 20;ScryerMcpServer::with_first_query_waitin tests). If the wait runs out, the answer saysINDEXING 62%: results so far are partial; retry in about N s, carries nocomplete, and drops the "no indexed symbol named X" notes, because they would be false. - Refresh the files the call names. The watcher's in-flight batches are drained (at most 50 ms), the
file_pathargument is checked, and after the handler answers, every file the answer names (file_path,locationandcallers/calleesentries) is checked again:EngineService::refresh_filesstats it (size and mtime, remembered in memory for files whose hash matched the index; the index stores no mtime) and re-indexes a file that changed, drops one that was deleted, and indexes a new source file that is asked about. At most 64 files per call. If anything changed, the handler runs once more on the refreshed index. - Say what happened. The text header's index part reads
index: fresh, <facts>orindex: refreshed 2 files, <facts>; JSON gets arefreshed 2 files: a.rs, b.rsnote. During a re-index (Stale), a call that touches only unchanged files answers normally; one that names a pending file saysSTALE: k file(s) pending re-index (a.rs, ...): results for them may be out of date; retry in a few seconds.
The watcher turns a burst of more than 32 file events into one parallel index_project instead of file-by-file updates (branch switch, pull, formatter run). Measured timings are in docs/learnings/indexing-freshness.md.
Tool Routers & Inventory
1. Administration Router (crates/scryer-mcp/src/tools/admin.rs)
| Tool Name | Parameters | Description |
|---|---|---|
list_projects |
{ query?: string, offset?: number, limit?: number, max_tokens?: number, no_truncate?: bool } |
Use before project-scoped queries to inspect registered projects or confirm the active project; returns file counts and timestamps. |
switch_active_project |
{ project: string } |
Use when later calls without an explicit project should target a different registered project. |
delete_project |
{ project: string } |
Use only for explicitly intended removal; deletes the project's index data and ADR database records, while source files and ADR Markdown remain. |
index_workspace |
{ path?: string, project?: string, full?: bool } |
Use to create or refresh the code index when a workspace is new, missing, or stale; incremental by default. An explicit path is refused if it is a filesystem root or the home directory. |
get_indexing_status |
{ project?: string } |
Use to check whether indexing is running or queued (is_indexing; index_state is ready, building 45% (n/N) or stale (k files pending), with pending_files), to confirm indexed file count, or to see what could not be indexed. Counts come from SQL (file_count, symbol_count, reference_count, edge_count), with files_by_language, references_by_via, last_indexed, the last run's stats (parse failures with the first error, syntax_error_files, skipped files, unresolved_references, dependencies state) and a problems list, empty when nothing is wrong. scryer doctor prints the same report. |
get_token_savings_metrics |
{ session_id?: string } |
Use to review measured usage: calls, response tokens, truncations, follow-up rate and latency per tool; optionally filter by session. |
2. Navigation Router (crates/scryer-mcp/src/tools/navigation.rs)
| Tool Name | Parameters | Description |
|---|---|---|
resolve_definition |
{ file_path: string, line: number, col: number, project?: string } |
Use with a source location to follow a reference to its definition; line is 1-based and column is 0-based. Returns path, line span, signature, and docstring (with advisory notice if external file is unverified on disk). The docstring is cut to 3 lines (docstring_truncated) unless max_tokens / no_truncate is set. The index fallback follows a use stub to the definition it imports (dependency::follow_import); dependency results carry the absolute cache path and the package's crate name. |
find_references |
{ symbol: string, file_path?: string, view?: "callers" | "files" | "sites", file_filter?: string, role?: "call" | "type_annotation" | "import" | "reexport" | "read" | "write" | "value", via?: "exact" | "name" | "macro", scope_level?: "project" | "workspace_all" | "file" | "dependencies", offset?: number, limit?: number, project?: string, max_tokens?: number, no_truncate?: bool } |
Use to find where a symbol is used (see Response Shapes: view picks callers, files or sites; the default is callers for a function and files for role: import; limit defaults to 40, 100 for files), such as before refactoring or to see how external library symbols are consumed across the workspace; set scope_level: "dependencies" to search usages of external crates and re-exports. In the dependencies scope a crate-qualified public path with no exact match falls back to the item name within the crate (tokio::sync::Mutex → tokio::sync::mutex::Mutex), and references to workspace use stubs that import a matched definition are included (workspace references link to the stub, not the definition). Each reference carries via (how it was resolved; name may be another symbol of the same name) and the via filter keeps one provenance. In the project scope symbol also accepts a trailing part of the qualified name (Config::new, Holder.attribute), which tells apart the same method name on different types. A reference held by no symbol (a script's top-level statement) reports its module as enclosing_symbol. Sites carry text, the trimmed source line (omitted when the file changed since indexing). When several definitions that are used share the name, the answer is ambiguous: true with definitions (qualified name, kind, location, usages) instead of a merged list; file_path or a qualified name picks one (the files view keeps the union, marked ambiguous). |
get_enclosing_scope |
{ file_path?: string, line?: number, lines?: number[], at?: string[], project?: string } |
Use when you know a file and line (stack trace, diff, grep hits) but need the function, method or type containing it. A single line returns the smallest enclosing named symbol (name, qualified_name, kind, signature, start_line/end_line, doc summary; use stubs never count), about 40 lines of its code as snippet (starting at snippet_start_line, shortened to the budget with snippet_truncated), and innermost_scope { scope_kind, name?, start_line, end_line } where an unnamed block or closure is named by its parent (crate::run::closure@L12). lines (up to 25) returns scopes, one group per containing symbol with each line's source text and block. at takes up to 25 path:line entries across files (grep -n hits; trailing text is ignored) and groups by file and symbol, each group with its file_path. Give file_path with line or lines, or at. |
get_file_outline |
{ file_path: string, query?: string, kinds?: string[], start_line?: number, end_line?: number, offset?: number, limit?: number, compact?: bool, project?: string, max_tokens?: number, no_truncate?: bool } |
Use to orient in a file or locate declarations before reading implementation details; omits function bodies. kinds takes indexed kinds (fn, struct, enum, trait, type, mod, const, reexport; class, method, interface for Python/TypeScript) or aliases (function/method → fn+method, class ↔ struct, interface ↔ trait, module → mod, import/use → reexport). An unindexed file, or a kinds filter that matches nothing, adds a notes hint such as the kinds present. Each declaration has a one-sentence doc summary (docstring; the whole comment when max_tokens or no_truncate is passed). Over budget it sheds detail in a fixed order (doc summaries, parameter lists, the name and visibility fields that repeat other fields), listing what it dropped in shed, and then pages; private items are never dropped. |
get_type_contract |
{ type_name: string, file_path?: string, member_query?: string, project?: string, max_tokens?: number, no_truncate?: bool } |
Use to inspect a type's shape before changing fields, variants, or trait behavior; returns its members and relevant implementations: Rust fields, variants, trait items and impl methods; Python methods and class attributes; TypeScript fields, constructor, methods, interface members and enum variants. The name resolves through lookup_symbol_candidates first (qualified names, public dependency paths like tokio::sync::Mutex, and file_path to pick among same-named types). members come before docstring; the docstring is cut to 3 lines (docstring_truncated) unless max_tokens / no_truncate is set. |
3. Dependency Discovery Router (crates/scryer-mcp/src/tools/dependency.rs)
| Tool Name | Parameters | Description |
|---|---|---|
get_crate_outline |
{ crate_name: string, version?: string, module_path?: string, project?: string, max_tokens?: number, no_truncate?: bool } |
Use when exploring a third-party crate for the first time, deciding which module or item to import, or discovering available types and traits without reading file trees or implementation bodies. |
3b. Symbol Search Router (crates/scryer-mcp/src/tools/search.rs)
| Tool Name | Parameters | Description |
|---|---|---|
search_symbols |
{ query?: string, like_symbol?: string, kinds?: string[], file_filter?: string, scope_level?: "project" | "dependencies", crate_name?: string, offset?: number, limit?: number, compact?: bool, project?: string, max_tokens?: number, no_truncate?: bool } |
Use when you don't know a symbol's exact name: BM25-ranked search over identifiers (camelCase/snake_case split), qualified paths, signatures, docstrings and file paths. Exactly one of query / like_symbol. limit defaults to 8. Each hit has its signature and a doc summary; the best hit of a query carries snippet (its first 10 lines), and confident: true means its score is at least 1.5 times the second's and it matched every term. A hit in test code (a tests directory, test_*.py, *.test.ts, an inline mod tests) scores half, so source ranks first; file_filter: "tests" reaches the tests. Replaces search_dependency_symbols. |
- Results carry
score(2 dp),matched_terms, and for dependency hitscrate_name/version;total,has_moreandoffsetsupport pagination (limitdefaults to 20, max 50). like_symbolbuilds the query from the target symbol's own name, module path, signature and docstring, and excludes the target from the results.scope_level: "dependencies"searches only the packages the project links (project_dependency);crate_namenarrows to one crate (case-insensitive) and falls back to any cached version of that crate when the project doesn't link it.- The index lives in memory in
EngineService(seecrates/scryer-engine/README.md, "Lexical Search") and is rebuilt after the project is re-indexed.
3c. Symbol Inspection Router (crates/scryer-mcp/src/tools/inspect.rs)
| Tool Name | Parameters | Description |
|---|---|---|
inspect_symbol |
{ symbol?: string, symbols?: string[], file_path?: string, include?: string[], snippet_lines?: number, limit?: number, project?: string, max_tokens?: number, no_truncate?: bool } |
Use when you know a symbol's name: one call returns its definition, a source snippet, 1-hop callers/callees, a reference count and related ADR invariants (types get a member summary instead). symbols (up to 5) returns results, one section each in the order asked, each with a shorter default snippet, sharing the budget (500 tokens each). Replaces get_symbol_docs and get_symbol_invariants. |
Every section is a capped summary; with all sections on, the default response fits the 1,000-token budget by design (worst-case fixture: ~920 tokens).
- Resolution (
dependency::lookup_symbol_candidates): exact name, or qualified name /::- or.-suffix whensymbolis qualified, in the active project, skippingreexportstubs. With no workspace hit it falls back to the project 0 dependency cache (linked crates first); a crate-qualified public path with no exact match (e.g.tokio::sync::Mutex, defined attokio::sync::mutex::Mutex) falls back to the bare name within that crate.file_path(relative or absolute, suffix-matched) narrows candidates. - Ambiguity: more than one candidate returns
ambiguous: true,candidate_countand up to 10candidates(qualified_name,kind,location); sections are never merged across candidates. - Header:
qualified_name,kind,file_path(project-relative, or absolute into the Cargo cache for dependencies),start_line/end_line,signature,docstring(first 3 lines / 400 chars,docstring_truncated). - Snippet: read from disk by the stored line range, capped at
snippet_lines(default 25) and, when the caller sets none ofsnippet_lines/max_tokens/no_truncate, at 1,200 characters; lines over 200 chars are cut with…. When cut:snippet_truncated,snippet_remaining { file_path, start_line, end_line }(first unshown line → symbol end) and asee_alsoline"rest of body: lines X-Y in <path>". A BLAKE3 mismatch againstSourceFile.content_hashadds the note "file changed since index"; an unreadable file drops the snippet with a note, never failing the call. - Token budget: the payload limiter cuts line by line and the snippet is a single JSON line, so
server.rscallsinspect::fit_snippet_to_budgetafter the handler: if the response exceeds the budget (default 1,000 tokens;max_tokensoverrides,no_truncateskips), the snippet is shortened to the largest prefix that fits (samesnippet_truncated/snippet_remaining/see_alsopointers, plus a note) instead of losing every later field.get_crate_outlinedoes the same throughdependency::fit_crate_outline_to_budget, keeping the first modules and types that fit, rebuildingformatted_markdownfrom them and reportingomitted. - Callers / callees (functions, workspace only): distinct 1-hop
calls/implements/instantiatesneighbours as"name — path:line", capped atlimit(default 5), withcaller_count/callee_count. reference_count:navigation::count_referencesfor the resolved definition only (workspace: same-project references; dependency: all projects,reexportsedges, and references to workspaceusestubs that import it, matched by crate and item name viadependency::imports_symbol).- Invariants (workspace only): top 3 from
adr_rank::rank_invariants_for_symbolas{ adr_number, title, reason }. - Members (
struct/enum/trait/type/class/interface): first 8 fromEngineService::get_type_contractplusmember_count, replacing callers/callees. include: any ofsnippet,callers,callees,references,invariants,members; the header is always returned. Unknown names add a note.see_alsonames the deep-dive tool for every capped section (trace_call_hierarchy,find_references,get_type_contract,query_adrs).
4. Graph Traversal Router (crates/scryer-mcp/src/tools/graph.rs)
| Tool Name | Parameters | Description |
|---|---|---|
trace_call_hierarchy |
{ symbol: string, file_path?: string, direction?: "outbound" | "inbound" | "callees" | "callers", max_depth?: number, limit?: number, offset?: number, file_filter?: string, project?: string, max_tokens?: number, no_truncate?: bool } |
Use to understand call relationships: inbound finds callers, outbound follows calls made; default depth is 2, maximum 5. The start symbol resolves like inspect_symbol (qualified names; use stubs skipped); several definitions return ambiguous: true with candidates (each with usages, largest first) until file_path or a qualified name picks one. Returns the start's qualified_name. |
calculate_blast_radius |
{ symbol?: string, file_path?: string, limit?: number, include_symbols?: bool, project?: string, max_tokens?: number, no_truncate?: bool } |
Use before changing a symbol or file to assess indexed dependents and downstream impact. Several used definitions of one name return ambiguous: true with candidates (and file_path or a qualified name picks one). |
5. Architectural Memory Router (crates/scryer-mcp/src/tools/adr.rs)
| Tool Name | Parameters | Description |
|---|---|---|
query_adrs |
{ query: string, status?: string, offset?: number, limit?: number, compact?: bool, project?: string, max_tokens?: number, no_truncate?: bool } |
Use before architectural changes to find related decisions and lessons by keyword or natural-language query. |
record_adr |
{ title: string, context: string, decision: string, consequences?: string, affected_paths?: string[], status?: "proposed" | "accepted" | "deprecated" | "superseded", project?: string } |
Use after an architectural decision is made and should be preserved; writes an ADR to Markdown and Turso. |
delete_adr |
{ adr_number?: number, file_path?: string, title?: string, project?: string } |
Use only for explicitly intended ADR deletion; removes the record from repository disk and Turso. |
ADR Ranking (crates/scryer-mcp/src/tools/adr_rank.rs)
- Corpus: every
.mdfile directly underdocs/adr,docs/learningsanddocs/plans, plus DB-onlyArchitecturalDecisionrows. Cached per project in a process-wide map (daemon sessions each build a fresh server) keyed on the(max mtime, file count)of those directories;record_adr/delete_adrinvalidate it explicitly. affected_pathsglobs (globset, case-insensitive,*does not cross/): a bare entryfoo/barmatchesfoo/barandfoo/bar/**.query_adrs: BM25F over title (×3), decision (×2), context / consequences / body / affected_paths (×1), plus +3 when a path-like query (contains/or has an extension) is covered by an ADR glob or contains one (src/net→src/net/client/**).- Symbol invariants (
rank_invariants_for_symbol, surfaced byinspect_symbol): resolves the symbol to its definition and file, then scores each ADR: +5 glob covers the symbol's file, else +3 covers an ancestor directory; +2 covers a 1-hop caller/callee file (calls/implements/instantiatesedges; only when the file itself isn't covered); +0.2 per path segment of the matching glob (max +1) so exact-file ADRs beat crate-wide ones; and a BM25 text match on the symbol's name, module path and docstring normalised to 0..3. - Every match keeps the original fields (
relevance_scoreis the rounded score, at least 1) and addsscoreandmatch_reasons, e.g."affected_path 'src/net/**' covers callee src/net/client.rs","text: ingest, batch".
ADR Lifecycle and Removal Mechanics
- Creation (
record_adr): Creates a numbered Markdown file indocs/adr/{:04}-{slug}.mdwith YAML frontmatter, then synchronizes it into the Turso database tableArchitecturalDecision. - Deletion via MCP (
delete_adr): Deletes the markdown file from disk and purges the corresponding Turso record in one operation. An explicitfile_pathmust resolve to a.mdfile inside the project'sdocs/adrdirectory (absolute paths and..escapes are refused). Once an ADR number is known, only that ADR's rows are removed; a baretitlematching several ADRs with different numbers is an error asking foradr_number. - Write order and escaping (
record_adr): the DB row is inserted first and the file created exclusively (create_new); if the file write fails the row is deleted again, so no orphan is left. Titles are collapsed to one line, and title, status andaffected_pathsare written as escaped YAML scalars, so quotes or newlines can't break or forge the frontmatter. - External File Deletion (e.g.
rm,git checkout): If an ADR file is deleted directly on disk outside MCP,query_adrsperforms self-healing reconciliation: it detects the missing file on disk and purges the orphaned record from the Turso database (when the ADR corpus is next rebuilt, which the file deletion itself triggers).
Context Resolution Cascade
For any incoming tool request, Scryer determines the tenant project_id using a 3-tier waterfall:
- Tier 1 (Canonical Path Matching): If
file_pathis present, resolve longest prefix match against registeredProject.root_pathentries. - Tier 2 (Explicit Project Parameter): If
projectis passed, lookupProject.slugor ID inProjectRegistry. - Tier 3 (MCP Client Session Root / Active Project): Fall back to session active project ID (set by
switch_active_project) or session roots.
Session roots come from the client's roots/list when it supports roots (refreshed on notifications/roots/list_changed); otherwise from the session cwd sent in the daemon handshake. Relative file_path / path arguments are joined onto the session cwd before resolution. All of this state is per session: ScryerMcpServer::new builds a fresh ProjectContextResolver for each daemon connection and each HTTP session.
Shared Daemon (src/daemon/)
A local Turso database file takes an exclusive lock, so only one process may open it. The daemon is that process; scryer serve (stdio) is a thin client that proxies to it.
| Module | Role |
|---|---|
paths.rs |
DaemonPaths: runtime files in ~/.scryer/run/, keyed by a stable FNV-1a hash of the canonical database path (<hash>.sock, .pid, .lock, .log). Falls back to $XDG_RUNTIME_DIR//tmp for the socket if the path is too long to bind. |
handshake.rs |
One JSON line each way before MCP traffic. Client: {"scryer":1,"version":"x.y.z","mode":"mcp","cwd":"/abs","watch":true,"options":{...}} (options is a SessionOptions and may be omitted; an unknown project gets {"ok":false,...}) or {"scryer":1,...,"mode":"control","cmd":"status"|"stop"|"stop_if_idle"}. Daemon: {"ok":true,"version":"x.y.z","status":{...}?} or {"ok":false,"error":"..."}. |
server.rs |
run_daemon / Daemon::serve: takes the .pid lock (singleton), opens the database once, accepts connections, and runs one ScryerMcpServer session per connection. Exits on stop, SIGINT/SIGTERM, or after the idle timeout with zero sessions; removes the socket on exit. |
server.rs (watching) |
One scryer_engine::WorkspaceWatcher covers every registered project. On start, the daemon watches all projects and runs a background catch-up index of each. It then follows ProjectRegistry::subscribe events (Registered → watch, Removed → unwatch). A watch: true session (the default when the field is omitted) whose cwd is outside every project registers it and indexes it in the background (registration::ensure_cwd_registered, shared with in-process scryer serve) if scryer_db::is_registrable_root accepts it. |
client.rs |
connect_or_spawn (connect; else take the .lock file lock, re-check, clear a stale socket, spawn, and poll; on failure, report the log tail), handshake, control, spawn_daemon_process (detached scryer daemon run, stderr to the log), and pipe (stdio ⇄ socket). |
Handshake bytes are read through a BufReader that is then handed to rmcp, so MCP messages sent in the same write as the handshake are not lost. Index writes are serialized per project in EngineService, so watchers, tools, and CLI runs never index the same project concurrently.
Token Budget, Usage Telemetry & Payload Controls
All tool calls are intercepted by TokenSavingsMiddleware:
- Measures exact response payload token count using
tiktoken-rs(cl100k_base). - Persists the call asynchronously to
ToolInvocationMetricin Turso: tool, session, project, response tokens, whether the answer was truncated (cut at the budget, or a page withnext.next_offset), latency. Never arguments or answers.SCRYER_TELEMETRY=offdisables it. get_token_savings_metrics(andscryer report) aggregate those rows into measured usage per tool: calls, response tokens, truncations and a follow-up rate (a call followed by another Scryer call in the same session; a lower bound, since agrepafter an answer is invisible to the server). The earlier whole-file-read savings estimate and dollar figure are retired because agents do not read whole files; cost and accuracy claims come from the harness (testing-harness/).- Enforces a per-tool default budget (1,000 tokens, more for the tools above) per payload; see Response Shapes and Budgets.
- Bypass: Pass
no_truncate: trueto receive the complete, untruncated output. - Custom Budget: Pass
max_tokens: Nto expand the budget. - Selective Querying & Pagination: Collection tools support filtering (
query,kinds,start_line/end_line,role,file_filter,compact) and pagination (offset,limit) with structured metadata (total_count,returned_count,offset,limit,has_more).
- Bypass: Pass
Usage
use Arc;
use ;
use EngineService;
use ScryerMcpServer;
async