scryer-mcp 0.2.1

Model Context Protocol (MCP) server for Scryer code intelligence
docs.rs failed to build scryer-mcp-0.2.1
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 rmcp v3.5.1 ServerHandler protocol 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_tool in src/tools/admin.rs): query tools are readOnlyHint; index_workspace / switch_active_project are idempotent writes; record_adr writes; delete_project / delete_adr are destructiveHint.
  • 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-rs against category-specific naive baselines, enforcing a hard 1,000 token maximum limit and persisting metrics to ToolInvocationMetric in 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_paths globs (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): plain type (no ["string","null"]), inline enum, 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, direction and status list their valid values in the schema, and a wrong value is an error naming the choices. direction also accepts callers / callees.
  • max_tokens, no_truncate and project are described once in schema.rs and 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, and null for an absent optional all mean what the agent intended. A string that isn't a number is still an error.
  • An explicit project must 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_project and index_workspace (nonexistent or non-directory path) fail the same way.
  • Required combinations are enforced with a message: calculate_blast_radius needs symbol or file_path; search_symbols exactly one of query / like_symbol; delete_adr one of adr_number / file_path / title; scope_level: "file" needs file_path. Lines are 1-based; line: 0 is an error.
  • References name their container: each find_references item carries enclosing_symbol (the qualified name of the function or type it sits in), so "who calls X" is one call, not one get_enclosing_scope per site.
  • Empty means explained: find_references, calculate_blast_radius, get_type_contract, get_enclosing_scope, query_adrs and get_file_outline return notes when the name, path or filter matched nothing; resolve_definition puts the reason in notice; get_crate_outline explains in formatted_markdown. An unknown name is distinguishable from "no usages".
  • Server instructions (SERVER_INSTRUCTIONS in server.rs, sent at initialize) 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 hits crate_name / version; total, has_more and offset support pagination (limit defaults to 20, max 50).
  • like_symbol builds 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_name narrows 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 (see crates/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 when symbol is qualified, in the active project, skipping reexport stubs. 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 at tokio::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_count and up to 10 candidates (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 of snippet_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 a see_also line "rest of body: lines X-Y in <path>". A BLAKE3 mismatch against SourceFile.content_hash adds 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.rs calls inspect::fit_snippet_to_budget after the handler: if the response exceeds the budget (default 1,000 tokens; max_tokens overrides, no_truncate skips), the snippet is shortened to the largest prefix that fits (same snippet_truncated / snippet_remaining / see_also pointers, plus a note) instead of losing every later field. get_crate_outline does the same through dependency::fit_crate_outline_to_budget, keeping the first modules and types that fit, rebuilding formatted_markdown from them and reporting omitted.
  • Callers / callees (functions, workspace only): distinct 1-hop calls / implements / instantiates neighbours as "name — path:line", capped at limit (default 5), with caller_count / callee_count.
  • reference_count: navigation::count_references for the resolved definition only (workspace: same-project references; dependency: all projects, reexports edges, and references to workspace use stubs that import it, matched by crate and item name via dependency::imports_symbol).
  • Invariants (workspace only): top 3 from adr_rank::rank_invariants_for_symbol as { adr_number, title, reason }.
  • Members (struct / enum / trait / type / class / interface): first 8 from EngineService::get_type_contract plus member_count, replacing callers/callees.
  • include: any of snippet, callers, callees, references, invariants, members; the header is always returned. Unknown names add a note.
  • see_also names 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 .md file directly under docs/adr, docs/learnings and docs/plans, plus DB-only ArchitecturalDecision rows. 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_adr invalidate it explicitly.
  • affected_paths globs (globset, case-insensitive, * does not cross /): a bare entry foo/bar matches foo/bar and foo/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 by inspect_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 / instantiates edges; 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_score is the rounded score, at least 1) and adds score and match_reasons, e.g. "affected_path 'src/net/**' covers callee src/net/client.rs", "text: ingest, batch".

ADR Lifecycle and Removal Mechanics

  1. Creation (record_adr): Creates a numbered Markdown file in docs/adr/{:04}-{slug}.md with YAML frontmatter, then synchronizes it into the Turso database table ArchitecturalDecision.
  2. Deletion via MCP (delete_adr): Deletes the markdown file from disk and purges the corresponding Turso record in one operation. An explicit file_path must resolve to a .md file inside the project's docs/adr directory (absolute paths and .. escapes are refused). Once an ADR number is known, only that ADR's rows are removed; a bare title matching several ADRs with different numbers is an error asking for adr_number.
  3. 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 and affected_paths are written as escaped YAML scalars, so quotes or newlines can't break or forge the frontmatter.
  4. External File Deletion (e.g. rm, git checkout): If an ADR file is deleted directly on disk outside MCP, query_adrs performs 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:

  1. Tier 1 (Canonical Path Matching): If file_path is present, resolve longest prefix match against registered Project.root_path entries.
  2. Tier 2 (Explicit Project Parameter): If project is passed, lookup Project.slug or ID in ProjectRegistry.
  3. 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:

  1. Measures exact response payload token count using tiktoken-rs (cl100k_base).
  2. 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
  3. Computes $\text{Tokens Saved} = \max(\text{NaiveCost} - \text{ActualPayloadTokens}, 0)$.
  4. Persists the record asynchronously to ToolInvocationMetric in Turso.
  5. Enforces a default budget of 1,000 tokens per payload.
    • Bypass: Pass no_truncate: true to receive the complete, untruncated output.
    • Custom Budget: Pass max_tokens: N to 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).

Usage

use std::sync::Arc;
use scryer_db::{ScryerDb, ProjectRegistry};
use scryer_engine::EngineService;
use scryer_mcp::ScryerMcpServer;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let db = ScryerDb::connect("turso::memory:").await?;
    let engine = EngineService::new(db.clone());
    let registry = Arc::new(ProjectRegistry::new(db.inner()).await?);

    let server = ScryerMcpServer::new(db, engine, registry, None)?;

    // Run over stdio:
    rmcp::serve_server(server, rmcp::transport::io::stdio()).await?;
    Ok(())
}