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.
- Deterministic Token Savings Interceptor: Measures exact payload token counts using
tiktoken-rsagainst category-specific naive baselines, enforcing a hard 1,000 token maximum limit and persisting metrics toToolInvocationMetricin Turso. - 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 Savings Interceptor
(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.rsand shared by every tool; tools with tiny outputs (the administration and ADR-write tools) don't advertise the budget parameters, though the server still honours them.- 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_INSTRUCTIONSinserver.rs, sent atinitialize) carry the which-tool decision tree and these conventions for every client. A test fails if they name a tool that doesn't exist or leave a tool unreachable.
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, or to confirm indexed file count. |
get_token_savings_metrics |
{ session_id?: string } |
Use to review tool usage (by_tool invocation counts), token savings, latency, and estimated cost impact; 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, file_filter?: string, role?: "call" | "type_annotation" | "import" | "reexport" | "read" | "write", 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, 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). |
get_enclosing_scope |
{ file_path: string, line: number, project?: string } |
Use when you know a file and line but need the function, method or type containing it. Returns the smallest enclosing named symbol (name, qualified_name, kind, signature, start_line/end_line; use stubs never count) plus innermost_scope { scope_kind, start_line, end_line }, the innermost lexical scope (block, closure, …) at the line. |
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. |
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. 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. 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, 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). 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, 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 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. |
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} 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 whose cwd is outside every project registers it 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.
Deterministic Token Savings Interceptor & Payload Controls
All tool calls are intercepted by TokenSavingsMiddleware:
- Measures exact response payload token count using
tiktoken-rs(cl100k_base). - Calculates estimated naive baseline exploration cost:
- Outline / Scope:
File Bytes / 3.8 - Definition / Type:
max(File Bytes / 3.8, 1500) - References / Hierarchy:
min(Matches, 5) * max(File Bytes / 3.8, 1200) - Default: 1,000
- Outline / Scope:
- Computes $\text{Tokens Saved} = \max(\text{NaiveCost} - \text{ActualPayloadTokens}, 0)$.
- Persists the record asynchronously to
ToolInvocationMetricin Turso. - Enforces a default budget of 1,000 tokens per payload.
- 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