From fec5266fe79c51cd5ecfcc7691d8812f95c60ea7 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 28 Aug 2026 01:33:35 +0200 Subject: [PATCH 1/2] =?UTF-8?q?FEAT-066:=20scry-mcp=20MCP=20server=20?= =?UTF-8?q?=E2=80=94=20analyze=20+=20query=20as=20agent=20tools,=20verify?= =?UTF-8?q?=20structurally=20absent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New publishable crate crates/scry-mcp (scry-sai-mcp): an MCP server over newline-delimited JSON-RPC 2.0 on stdio (initialize / tools/list / tools/call), hand-rolled on serde_json — deliberately no MCP SDK dependency (nothing new to clear cargo-deny). Modeled on scry-viz: a plain std host tool whose only analyzer dependency is scry-sai-core. Tools (the full v3.3.0 surface): - analyze: module path (.wasm/.wat) -> compact structured summary — advisory counts by class and code, trap verdicts (proven-safe / potential-trap), gap counts. Never HTML, never a multi-MB dump (AC#1). - query: AnalysisResult::query (FEAT-067) over MCP — class / code / func_index / op / gap_kind filters, ANDed; matches carry the REQ-020 stable obligation identity + honesty flags; limit-capped with an exact total_matches. AC#2 is structural (DD-022 family): `verify` is ABSENT from the tools/list payload itself, and the test asserts against that actual payload — not documentation. The deferral is measured, not cautious: REQ-021 found FEAT-065's verify_against yields discharged=0 with every verdict degrading to `uncertain` on real (stripped) inputs; exposing that over MCP would put an always-uncertain verdict directly into an agent's tool loop. The tool follows FEAT-065 into v3.4.0. Verification: 10 tests, each written RED-first against a stub (all 10 observed failing), then GREEN; 5 mutants (verify added to the tool list, by_class count inverted, query filters ignored, limit cap dropped, notifications answered) each applied at exactly 1 site, each compiled with 0 errors, each killed by exactly the targeted test. End-to-end stdio smoke of the binary passed. check-gate-coverage.py negative control observed red with the crate missing from a gate. Wiring (all four places): workspace members + default-members; BOTH cargo test and cargo clippy lists in ci.yml (check-gate-coverage.py: 13 crates in both gates); scripts/publish.rs after scry-sai-core (leaf-before-core); no new CI job so required-checks.txt untouched. README + claims.yaml crate count moved 12 -> 13 in lockstep (CRATES-13). Known mid-cycle condition (pre-existing pattern, not introduced here): cargo package verify of scry-sai-mcp fails against crates.io scry-sai-core 3.2.7, which predates the Query API — identical to scry-sai-viz on main (uses FEAT-065 types absent from 3.2.7). Resolved at release by the version bump + leaf-first publish order. Refs: FEAT-066 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkNzkNYzPh7366DkNijeNc --- .github/workflows/ci.yml | 4 +- Cargo.lock | 9 + Cargo.toml | 2 + README.md | 7 +- claims.yaml | 14 +- crates/scry-mcp/Cargo.toml | 33 ++ crates/scry-mcp/README.md | 31 ++ crates/scry-mcp/src/lib.rs | 755 ++++++++++++++++++++++++++++++++++++ crates/scry-mcp/src/main.rs | 37 ++ scripts/publish.rs | 3 + 10 files changed, 883 insertions(+), 12 deletions(-) create mode 100644 crates/scry-mcp/Cargo.toml create mode 100644 crates/scry-mcp/README.md create mode 100644 crates/scry-mcp/src/lib.rs create mode 100644 crates/scry-mcp/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1bc9e3..9a465eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: # and drifted independently, so the guard below now checks BOTH. - name: cargo clippy — analyzer core, viz, and the domain crates run: | - cargo clippy -p scry-sai-core -p scry-sai-viz -p scry-sai-interval \ + cargo clippy -p scry-sai-core -p scry-sai-viz -p scry-sai-mcp -p scry-sai-interval \ -p scry-sai-bits -p scry-sai-float -p scry-sai-handle \ -p scry-sai-pentagon -p scry-sai-segment -p scry-sai-poly \ --all-targets -- -D warnings @@ -227,7 +227,7 @@ jobs: python3 tools/check-gate-coverage.py - name: cargo test — analyzer core, viz, and the domain crates run: | - cargo test -p scry-sai-core -p scry-sai-viz -p scry-sai-interval \ + cargo test -p scry-sai-core -p scry-sai-viz -p scry-sai-mcp -p scry-sai-interval \ -p scry-sai-bits -p scry-sai-float -p scry-sai-handle \ -p scry-sai-pentagon -p scry-sai-segment -p scry-sai-poly \ -- --nocapture diff --git a/Cargo.lock b/Cargo.lock index 4216558..7dca350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1833,6 +1833,15 @@ dependencies = [ "scry-sai-taint", ] +[[package]] +name = "scry-sai-mcp" +version = "3.2.7" +dependencies = [ + "scry-sai-core", + "serde_json", + "wat", +] + [[package]] name = "scry-sai-octagon" version = "3.2.7" diff --git a/Cargo.toml b/Cargo.toml index 34c0e41..5baf4f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "crates/scry-analyze-core", "crates/scry-host-tests", "crates/scry-viz", + "crates/scry-mcp", ] # `wasm-lattice` and `scry-analyzer` are the Wasm-component crates # (`crate-type = ["cdylib"]`, `#![no_std]`; `scry-analyzer` additionally @@ -62,6 +63,7 @@ default-members = [ "crates/scry-analyze-core", "crates/scry-host-tests", "crates/scry-viz", + "crates/scry-mcp", ] [workspace.package] diff --git a/README.md b/README.md index 5381e58..a76f18d 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,15 @@ deductive-proof and bounded-model-checking layers do not staff. ## status - + **v3.2.7 shipped** — the full v0.1 → v3.2 arc is done; scry is a working **sound -abstract interpreter**, not a scaffold. Shipped and on crates.io: **12 pure +abstract interpreter**, not a scaffold. Shipped and on crates.io: **13 pure `scry-sai-*` crates** (10 abstract domains — interval, region-memory, call-graph + reachability, octagon, pentagon, known-bits/congruence, IEEE-754 float, Component-Model handle-state, linear-memory segmentation, convex polyhedra — -plus the analyzer core and the viz) driving `analyze()` over parsed Wasm; a host +plus the analyzer core, the viz, and the `scry-mcp` MCP server) driving +`analyze()` over parsed Wasm; a host wasmtime harness; runtime-trap classification (PROVEN-SAFE vs POTENTIAL-TRAP); ranked remediation guidance + a structured `guidance.json`; and a GitHub Pages [verification dashboard](https://pulseengine.github.io/scry). diff --git a/claims.yaml b/claims.yaml index 0645e91..d6d6f67 100644 --- a/claims.yaml +++ b/claims.yaml @@ -95,20 +95,20 @@ claims: max: 0 # ── Published-crate count ───────────────────────────────────────────────── - # "12 pure scry-sai-* crates". Re-counted from the publish manifest; add a - # 13th crate and forget the README → 13 > 12 → red. - - id: CRATES-12 + # "13 pure scry-sai-* crates". Re-counted from the publish manifest; add a + # 14th crate and forget the README → 14 > 13 → red. + - id: CRATES-13 doc: README.md - text: "12 pure" + text: "13 pure" evidence: - kind: count-max pattern: '"scry-sai-[a-z]+"' glob: ['scripts/publish.rs'] - max: 12 - - kind: count-min # pin exact: a REMOVED crate also makes "12" stale + max: 13 + - kind: count-min # pin exact: a REMOVED crate also makes "13" stale pattern: '"scry-sai-[a-z]+"' glob: ['scripts/publish.rs'] - min: 12 + min: 13 # ── i32.add vs OFFICIAL wrapping semantics (the differentiator) ──────────── # The README's scoped soundness claim rests on WrapAdd.v existing. If the proof diff --git a/crates/scry-mcp/Cargo.toml b/crates/scry-mcp/Cargo.toml new file mode 100644 index 0000000..32a76be --- /dev/null +++ b/crates/scry-mcp/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "scry-sai-mcp" +description = "MCP (Model Context Protocol) server exposing the scry sound abstract interpreter to AI agents: `analyze` (module → structured summary) and `query` (FEAT-067 filters) as MCP tools over JSON-RPC 2.0 on stdio. Structured results only — never HTML, never a multi-MB dump (FEAT-066, TE-011 structured-primary)." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +keywords = ["wasm", "static-analysis", "mcp", "verification", "ai"] +categories = ["development-tools", "wasm"] + +[lib] +name = "scry_mcp" +path = "src/lib.rs" + +[[bin]] +name = "scry-mcp" +path = "src/main.rs" + +[dependencies] +# The analyzer library — same single-dependency pattern as scry-viz: a plain +# `std` host tool consuming the published `AnalysisResult` plain-Rust types. +scry-sai-core = { path = "../scry-analyze-core", version = "3.2.7" } +# Hand-rolled JSON-RPC 2.0 (DELIBERATE — no MCP SDK dependency): MCP's stdio +# transport is newline-delimited JSON-RPC and the server needs exactly three +# methods (initialize / tools/list / tools/call). serde_json covers that; an +# SDK would add a dependency tree that has to clear cargo-deny and buys +# nothing here. +serde_json = { workspace = true } +# Accept both `.wat` text and `.wasm` binary module paths (assembled +# in-process) — same input contract as the scry-viz CLI. +wat = { workspace = true } diff --git a/crates/scry-mcp/README.md b/crates/scry-mcp/README.md new file mode 100644 index 0000000..34bf09b --- /dev/null +++ b/crates/scry-mcp/README.md @@ -0,0 +1,31 @@ +# scry-sai-mcp + +MCP (Model Context Protocol) server exposing the scry sound abstract +interpreter to AI agents (FEAT-066): JSON-RPC 2.0, newline-delimited, over +stdio. Agents cannot run `cargo`; this crate replaces shelling out to a CLI or +scraping a multi-MB JSON dump with two structured tools: + +- **`analyze`** — run scry over a Wasm module (`.wasm` or `.wat`, by path) and + get a compact summary: advisory counts by actionability class and code, + runtime-trap verdicts (proven-safe vs potential-trap), and precision-gap + counts. Never HTML, never a full dump. +- **`query`** — filter the advisories (FEAT-067): `class`, `code`, + `func_index`, `op`, `gap_kind`, ANDed, each optional. Matches carry their + stable obligation identities (REQ-020) and honesty flags. + +`verify` is deliberately absent from the v3.3.0 tool list — the deferral is +enforced structurally, not by documentation: REQ-021 measured that on real +inputs the FEAT-065 adjudicator's `discharged` is 0 and every verdict degrades +to `uncertain`, which must not sit inside an agent's tool loop. It follows +FEAT-065 into v3.4.0. + +## Use + +```jsonc +// MCP client config (stdio server): +{ "command": "scry-mcp" } +``` + +Install: `cargo install scry-sai-mcp`. + +The JSON-RPC layer is hand-rolled on `serde_json` — no MCP SDK dependency. diff --git a/crates/scry-mcp/src/lib.rs b/crates/scry-mcp/src/lib.rs new file mode 100644 index 0000000..4497be2 --- /dev/null +++ b/crates/scry-mcp/src/lib.rs @@ -0,0 +1,755 @@ +//! scry-mcp — MCP server library (FEAT-066, REQ-017/REQ-020, TE-011). +//! +//! Exposes the scry sound abstract interpreter to AI agents over the Model +//! Context Protocol: JSON-RPC 2.0, newline-delimited, on stdio. Agents cannot +//! run `cargo`; before this crate, consuming scry meant shelling out to a CLI +//! or scraping a multi-MB JSON dump. The tool surface is deliberately +//! structured-primary (TE-011: agents under-read rendered output) — every +//! result is compact JSON, never HTML, never the full `AnalysisResult`. +//! +//! The JSON-RPC layer is HAND-ROLLED on `serde_json` by design: MCP's stdio +//! transport needs exactly three methods (`initialize`, `tools/list`, +//! `tools/call`) plus notification tolerance, and an MCP SDK dependency would +//! have to clear cargo-deny while buying nothing at this size. + +use scry_analyze_core::{AdvisoryClass, AnalysisConfig, GapKind, Query, TrapVerdict, analyze}; +use serde_json::{Map, Value, json}; + +/// The MCP protocol revision this server implements. +const PROTOCOL_VERSION: &str = "2025-06-18"; + +/// Cap on `query` matches returned in one response, overridable per call via +/// the `limit` argument. Keeps the worst case (an unconstrained query over a +/// large module) a bounded payload instead of a multi-MB dump (FEAT-066 AC#1); +/// `total_matches`/`truncated` always report what the cap hid. +const DEFAULT_QUERY_LIMIT: usize = 100; + +/// The v3.3.0 tool surface: `analyze` and `query` ONLY. +/// +/// `verify` is deliberately ABSENT, and its absence is enforced HERE — by the +/// tool list a client actually enumerates — not by documentation (FEAT-066 +/// AC#2; same family as DD-022: a deferral a consumer can rely on must be +/// structural). Do NOT "helpfully" add it: FEAT-065's `verify_against` exists +/// and is `accepted`, but REQ-021 MEASURED that on real inputs `discharged` +/// is 0 and every verdict degrades to `uncertain` (the identity tier on +/// stripped release builds is the body-shape hash, which an edit changes by +/// construction — see `Advisory::ident_survives_own_edit`). Exposing that +/// over MCP would put an always-`uncertain` verdict directly into an agent's +/// tool loop, the single worst place for it to land. `verify` follows +/// FEAT-065 into v3.4.0 with REQ-021. +fn tool_definitions() -> Value { + json!([ + { + "name": "analyze", + "description": "Run the scry sound abstract interpreter over a \ + Wasm module (.wasm binary or .wat text, by path) and return a \ + compact structured summary: advisory counts by actionability \ + class and code, runtime-trap verdicts (proven-safe vs \ + potential-trap), and precision-gap counts. Never HTML, never \ + a full dump — use the `query` tool to retrieve specific \ + advisory sites.", + "inputSchema": { + "type": "object", + "properties": { + "module_path": { + "type": "string", + "description": "Path to the module to analyze \ + (.wasm or .wat)." + } + }, + "required": ["module_path"] + } + }, + { + "name": "query", + "description": "Filter the advisories of a Wasm module's scry \ + analysis (FEAT-067): every given filter is ANDed, an omitted \ + filter is unconstrained. Returns the matching advisory sites \ + with their stable obligation identities (REQ-020), capped by \ + `limit` with an exact `total_matches` count.", + "inputSchema": { + "type": "object", + "properties": { + "module_path": { + "type": "string", + "description": "Path to the module to analyze \ + (.wasm or .wat)." + }, + "class": { + "type": "string", + "enum": ["definite-fault", "unproven-obligation", + "precision-gap", "leverageable-fact"], + "description": "Advisory actionability class." + }, + "code": { + "type": "string", + "description": "Advisory category code, e.g. \ + `div-by-zero`, `use-after-drop`, `proven-safe`." + }, + "func_index": { + "type": "integer", + "description": "Absolute function index." + }, + "op": { + "type": "string", + "description": "Operator name in wasm text format \ + (e.g. `i32.div_u`), joined from the gap / \ + trap-check record at the same site." + }, + "gap_kind": { + "type": "string", + "enum": ["unsupported-op", "unmodeled-branch", + "unmodeled-memory-address", + "unmodeled-control-flow"], + "description": "Gap kind, joined from the gap record \ + at the same site." + }, + "limit": { + "type": "integer", + "description": "Maximum matches to return \ + (default 100); `total_matches` is always exact." + } + }, + "required": ["module_path"] + } + } + ]) +} + +/// Handle one newline-delimited JSON-RPC message. Returns the response line +/// to write, or `None` when the message is a notification (no `id`) — a +/// notification must never be answered, or the stdio stream corrupts. +pub fn handle_line(line: &str) -> Option { + let msg: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + return Some( + error_response(Value::Null, -32700, &format!("parse error: {e}")).to_string(), + ); + } + }; + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + + // Requests carry an id; anything without one is a notification + // (e.g. `notifications/initialized`) and gets no response. + let id = id?; + + let resp = match method { + "initialize" => json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "scry-mcp", + "version": env!("CARGO_PKG_VERSION") + } + } + }), + "ping" => json!({ "jsonrpc": "2.0", "id": id, "result": {} }), + "tools/list" => json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "tools": tool_definitions() } + }), + "tools/call" => handle_tool_call(id, ¶ms), + other => error_response(id, -32601, &format!("method not found: {other}")), + }; + Some(resp.to_string()) +} + +fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }) +} + +/// A tool EXECUTION failure (unreadable path, invalid module): reported +/// in-band as an `isError` result so the agent sees it as tool output it can +/// react to, per MCP — reserved JSON-RPC errors are for protocol misuse. +fn tool_error(id: Value, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ "type": "text", "text": message }], + "isError": true + } + }) +} + +fn tool_result(id: Value, payload: &Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ "type": "text", "text": payload.to_string() }], + "structuredContent": payload, + "isError": false + } + }) +} + +fn handle_tool_call(id: Value, params: &Value) -> Value { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + match name { + "analyze" => run_analyze(id, &args), + "query" => run_query(id, &args), + // `verify` deliberately lands here too — see `tool_definitions`. + other => error_response(id, -32602, &format!("unknown tool: {other}")), + } +} + +/// Load + analyze the module named by `arguments.module_path`. `Err(String)` +/// is a USER-INPUT problem (missing/invalid argument → the caller maps it to +/// -32602); `Err` inside the returned `Result`'s `Ok` path never happens — +/// I/O and analysis failures are returned as `Ok(Err(msg))` for in-band +/// reporting. +#[allow(clippy::type_complexity)] +fn load_and_analyze( + args: &Value, +) -> Result, String> { + let path = args + .get("module_path") + .and_then(Value::as_str) + .ok_or_else(|| "missing required argument: module_path (string)".to_string())?; + let bytes = match wat::parse_file(path) { + Ok(b) => b, + Err(e) => return Ok(Err(format!("cannot load module `{path}`: {e}"))), + }; + match analyze(bytes, AnalysisConfig::default()) { + Ok(r) => Ok(Ok(r)), + Err(e) => Ok(Err(format!("analysis of `{path}` failed: {e:?}"))), + } +} + +fn class_str(c: AdvisoryClass) -> &'static str { + match c { + AdvisoryClass::DefiniteFault => "definite-fault", + AdvisoryClass::UnprovenObligation => "unproven-obligation", + AdvisoryClass::PrecisionGap => "precision-gap", + AdvisoryClass::LeverageableFact => "leverageable-fact", + } +} + +fn parse_class(s: &str) -> Option { + match s { + "definite-fault" => Some(AdvisoryClass::DefiniteFault), + "unproven-obligation" => Some(AdvisoryClass::UnprovenObligation), + "precision-gap" => Some(AdvisoryClass::PrecisionGap), + "leverageable-fact" => Some(AdvisoryClass::LeverageableFact), + _ => None, + } +} + +fn gap_kind_str(k: GapKind) -> &'static str { + match k { + GapKind::UnsupportedOp => "unsupported-op", + GapKind::UnmodeledBranch => "unmodeled-branch", + GapKind::UnmodeledMemoryAddress => "unmodeled-memory-address", + GapKind::UnmodeledControlFlow => "unmodeled-control-flow", + } +} + +fn parse_gap_kind(s: &str) -> Option { + match s { + "unsupported-op" => Some(GapKind::UnsupportedOp), + "unmodeled-branch" => Some(GapKind::UnmodeledBranch), + "unmodeled-memory-address" => Some(GapKind::UnmodeledMemoryAddress), + "unmodeled-control-flow" => Some(GapKind::UnmodeledControlFlow), + _ => None, + } +} + +/// `analyze` tool: the AC#1 structured summary — counts by advisory class and +/// code, trap verdicts, gap counts. COUNTS, not sites: the per-site data is +/// what `query` is for, which is how the summary stays kilobytes on a module +/// whose full `AnalysisResult` serializes to multiple MB. +fn run_analyze(id: Value, args: &Value) -> Value { + let r = match load_and_analyze(args) { + Err(bad_args) => return error_response(id, -32602, &bad_args), + Ok(Err(msg)) => return tool_error(id, &msg), + Ok(Ok(r)) => r, + }; + + let mut by_class = Map::new(); + for c in [ + AdvisoryClass::DefiniteFault, + AdvisoryClass::UnprovenObligation, + AdvisoryClass::PrecisionGap, + AdvisoryClass::LeverageableFact, + ] { + let n = r.advisories.iter().filter(|a| a.class == c).count(); + by_class.insert(class_str(c).to_string(), json!(n)); + } + let mut by_code = Map::new(); + for a in &r.advisories { + let e = by_code.entry(a.code.clone()).or_insert(json!(0)); + *e = json!(e.as_u64().unwrap_or(0) + 1); + } + let mut gaps_by_kind = Map::new(); + for g in &r.gaps { + let e = gaps_by_kind + .entry(gap_kind_str(g.kind).to_string()) + .or_insert(json!(0)); + *e = json!(e.as_u64().unwrap_or(0) + 1); + } + let n_potential = r + .trap_checks + .iter() + .filter(|t| t.verdict == TrapVerdict::PotentialTrap) + .count(); + let n_safe = r + .trap_checks + .iter() + .filter(|t| t.verdict == TrapVerdict::ProvenSafe) + .count(); + + let payload = json!({ + "functions": r.function_summaries.len(), + "advisories": { + "total": r.advisories.len(), + "by_class": by_class, + "by_code": by_code + }, + "trap_checks": { + "total": r.trap_checks.len(), + "proven-safe": n_safe, + "potential-trap": n_potential + }, + "gaps": { + "total": r.gaps.len(), + "by_kind": gaps_by_kind + } + }); + tool_result(id, &payload) +} + +/// `query` tool: `AnalysisResult::query` (FEAT-067) over MCP — each filter +/// argument maps to the corresponding `Query` field, ANDed, `None` when +/// omitted. +fn run_query(id: Value, args: &Value) -> Value { + let mut q = Query::default(); + if let Some(v) = args.get("class") { + let Some(c) = v.as_str().and_then(parse_class) else { + return error_response( + id, + -32602, + &format!( + "invalid class {v}: expected one of definite-fault, \ + unproven-obligation, precision-gap, leverageable-fact" + ), + ); + }; + q.class = Some(c); + } + if let Some(v) = args.get("gap_kind") { + let Some(k) = v.as_str().and_then(parse_gap_kind) else { + return error_response( + id, + -32602, + &format!( + "invalid gap_kind {v}: expected one of unsupported-op, \ + unmodeled-branch, unmodeled-memory-address, \ + unmodeled-control-flow" + ), + ); + }; + q.gap_kind = Some(k); + } + q.code = args.get("code").and_then(Value::as_str).map(String::from); + q.op = args.get("op").and_then(Value::as_str).map(String::from); + q.func_index = args + .get("func_index") + .and_then(Value::as_u64) + .map(|f| f as u32); + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map(|l| l as usize) + .unwrap_or(DEFAULT_QUERY_LIMIT); + + let r = match load_and_analyze(args) { + Err(bad_args) => return error_response(id, -32602, &bad_args), + Ok(Err(msg)) => return tool_error(id, &msg), + Ok(Ok(r)) => r, + }; + let matches = r.query(&q); + let total = matches.len(); + let shown: Vec = matches + .iter() + .take(limit) + .map(|a| { + json!({ + "func_index": a.func_index, + "pc": a.pc, + "class": class_str(a.class), + "code": a.code, + "detail": a.detail, + "suggested_action": a.suggested_action, + "verification": a.verification, + // REQ-020 stable identity + its honesty flags (FEAT-077/087): + // a consumer may treat a missing key in a later build as + // "site gone" ONLY when !id_build_local && + // ident_survives_own_edit; otherwise `uncertain` (REQ-021). + "obligation_id": a.obligation_id, + "site_key": a.site_key, + "group_key": a.group_key, + "id_build_local": a.id_build_local, + "ident_survives_own_edit": a.ident_survives_own_edit + }) + }) + .collect(); + let payload = json!({ + "total_matches": total, + "truncated": total > shown.len(), + "matches": shown + }); + tool_result(id, &payload) +} + +#[cfg(test)] +mod tests { + use super::handle_line; + use serde_json::{Value, json}; + use std::path::PathBuf; + + /// Fixture chosen so ALL the surfaces the tools summarize are non-empty: + /// `$div_unknown` divides by an unknown param (PotentialTrap → + /// UnprovenObligation `div-by-zero`), `$div_const` divides by 7 + /// (ProvenSafe → LeverageableFact `proven-safe`), `$branchy` has a + /// `br_table` (UnmodeledBranch gap → PrecisionGap `unmodeled-branch`). + const FIXTURE_WAT: &str = r#" +(module + (func $div_unknown (export "div_unknown") (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.div_u) + (func $div_const (export "div_const") (param i32) (result i32) + local.get 0 + i32.const 7 + i32.div_u) + (func $branchy (export "branchy") (param i32) (result i32) + (block + (block + local.get 0 + br_table 0 1 0)) + i32.const 2) +) +"#; + + /// Write the fixture to a temp `.wat` path the tools can be pointed at. + fn fixture_path(tag: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "scry_mcp_fixture_{}_{}.wat", + tag, + std::process::id() + )); + std::fs::write(&p, FIXTURE_WAT).unwrap(); + p + } + + /// The fixture's analysis, run DIRECTLY through the library — the ground + /// truth the tool responses are compared against (non-vacuity: every count + /// asserted on the tool output is first asserted non-trivial here). + fn ground_truth() -> scry_analyze_core::AnalysisResult { + let bytes = wat::parse_str(FIXTURE_WAT).unwrap(); + scry_analyze_core::analyze(bytes, scry_analyze_core::AnalysisConfig::default()).unwrap() + } + + fn rpc(id: u64, method: &str, params: Value) -> String { + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string() + } + + /// Send one request line, parse the one response line. + fn roundtrip(line: &str) -> Value { + let resp = handle_line(line).expect("request with an id must get a response"); + serde_json::from_str(&resp).expect("response must be valid JSON") + } + + fn call_tool(name: &str, args: Value) -> Value { + roundtrip(&rpc( + 7, + "tools/call", + json!({"name": name, "arguments": args}), + )) + } + + /// A tool result's structured payload: the JSON re-parsed from the text + /// content block (agents read exactly this). + fn tool_payload(resp: &Value) -> Value { + let result = resp.get("result").unwrap_or_else(|| { + panic!("expected a result, got: {resp}"); + }); + assert_ne!( + result.get("isError").and_then(Value::as_bool), + Some(true), + "tool call unexpectedly errored: {result}" + ); + let text = result["content"][0]["text"] + .as_str() + .expect("content[0].text must be a string"); + serde_json::from_str(text).expect("tool text content must itself be structured JSON") + } + + // ── initialize ──────────────────────────────────────────────────────── + + #[test] + fn initialize_reports_server_info_and_tools_capability() { + let resp = roundtrip(&rpc( + 1, + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}}), + )); + let result = &resp["result"]; + assert!( + result["protocolVersion"].is_string(), + "initialize must report a protocolVersion, got: {resp}" + ); + assert_eq!(result["serverInfo"]["name"], "scry-mcp"); + assert!( + result["capabilities"]["tools"].is_object(), + "server must declare the tools capability, got: {resp}" + ); + } + + // ── tools/list (AC#2 lives here) ────────────────────────────────────── + + #[test] + fn tools_list_is_analyze_and_query_and_verify_is_absent() { + let resp = roundtrip(&rpc(2, "tools/list", json!({}))); + let tools = resp["result"]["tools"] + .as_array() + .unwrap_or_else(|| panic!("tools/list must return a tools array, got: {resp}")); + let names: Vec<&str> = tools + .iter() + .map(|t| t["name"].as_str().expect("every tool has a name")) + .collect(); + // Non-vacuity: the enumeration is non-empty (an empty list would make + // the absence assert below pass while the server exposes nothing). + assert!(!names.is_empty(), "tools/list returned no tools"); + // The v3.3.0 surface, exactly. + assert_eq!(names, vec!["analyze", "query"]); + // FEAT-066 AC#2, asserted in its own words: `verify` is ABSENT from + // the actual tools/list payload — the deferral is enforced by the + // tool list, not by documentation. + assert!( + !names.contains(&"verify"), + "verify must be ABSENT from the v3.3.0 tool list (REQ-021)" + ); + // Every listed tool is callable-by-schema: name + description + + // an object inputSchema (what an MCP client renders to the model). + for t in tools { + assert!(t["description"].as_str().is_some_and(|d| !d.is_empty())); + assert_eq!(t["inputSchema"]["type"], "object"); + } + } + + #[test] + fn calling_verify_is_rejected_as_unknown_tool() { + let p = fixture_path("verify"); + let resp = call_tool("verify", json!({"module_path": p})); + let err = resp + .get("error") + .unwrap_or_else(|| panic!("verify must be rejected, got: {resp}")); + assert_eq!(err["code"], -32602); + assert!( + err["message"].as_str().unwrap().contains("verify"), + "error should name the unknown tool, got: {err}" + ); + } + + // ── analyze (AC#1) ──────────────────────────────────────────────────── + + #[test] + fn analyze_returns_structured_summary_matching_ground_truth() { + let truth = ground_truth(); + // Non-vacuity: the fixture genuinely exercises every summarized + // surface, so a summary of zeros CANNOT pass. + use scry_analyze_core::{AdvisoryClass, TrapVerdict}; + let n_unproven = truth + .advisories + .iter() + .filter(|a| a.class == AdvisoryClass::UnprovenObligation) + .count(); + let n_fact = truth + .advisories + .iter() + .filter(|a| a.class == AdvisoryClass::LeverageableFact) + .count(); + let n_gap_class = truth + .advisories + .iter() + .filter(|a| a.class == AdvisoryClass::PrecisionGap) + .count(); + let n_potential = truth + .trap_checks + .iter() + .filter(|t| t.verdict == TrapVerdict::PotentialTrap) + .count(); + let n_safe = truth + .trap_checks + .iter() + .filter(|t| t.verdict == TrapVerdict::ProvenSafe) + .count(); + assert!(n_unproven > 0 && n_fact > 0 && n_gap_class > 0); + assert!(n_potential > 0 && n_safe > 0); + assert!(!truth.gaps.is_empty()); + + let p = fixture_path("analyze"); + let resp = call_tool("analyze", json!({"module_path": p})); + let s = tool_payload(&resp); + + assert_eq!(s["functions"], truth.function_summaries.len()); + assert_eq!(s["advisories"]["total"], truth.advisories.len()); + assert_eq!( + s["advisories"]["by_class"]["unproven-obligation"], + n_unproven + ); + assert_eq!(s["advisories"]["by_class"]["leverageable-fact"], n_fact); + assert_eq!(s["advisories"]["by_class"]["precision-gap"], n_gap_class); + assert_eq!(s["advisories"]["by_class"]["definite-fault"], 0); + assert_eq!(s["trap_checks"]["total"], truth.trap_checks.len()); + assert_eq!(s["trap_checks"]["potential-trap"], n_potential); + assert_eq!(s["trap_checks"]["proven-safe"], n_safe); + assert_eq!(s["gaps"]["total"], truth.gaps.len()); + assert_eq!(s["gaps"]["by_kind"]["unmodeled-branch"], truth.gaps.len()); + + // AC#1 structural: a SUMMARY, not a dump and not HTML. The advisories + // field is an object of counts (no per-site array), the serialized + // text is small, and there is no markup. + let text = resp["result"]["content"][0]["text"].as_str().unwrap(); + assert!(s["advisories"].is_object() && !s["advisories"].is_array()); + assert!( + text.len() < 16 * 1024, + "analyze summary must stay small, got {} bytes", + text.len() + ); + assert!(!text.contains(" 0, "fixture must produce a div-by-zero advisory"); + assert!(n_all > n_div, "fixture must also produce OTHER advisories"); + + let p = fixture_path("query"); + let s = tool_payload(&call_tool( + "query", + json!({"module_path": p, "code": "div-by-zero"}), + )); + assert_eq!(s["total_matches"], n_div); + let matches = s["matches"].as_array().unwrap(); + assert_eq!(matches.len(), n_div); + for m in matches { + assert_eq!(m["code"], "div-by-zero"); + assert_eq!(m["class"], "unproven-obligation"); + assert!(m["func_index"].is_u64() && m["pc"].is_u64()); + // REQ-020: each match carries its stable obligation identity. + assert!(m["obligation_id"].as_str().is_some()); + } + + // Class filter, on a different class than the code filter above. + let s = tool_payload(&call_tool( + "query", + json!({"module_path": p, "class": "leverageable-fact"}), + )); + let n_fact = truth + .advisories + .iter() + .filter(|a| a.class == scry_analyze_core::AdvisoryClass::LeverageableFact) + .count(); + assert!(n_fact > 0); + assert_eq!(s["total_matches"], n_fact); + + // A code the fixture does NOT produce: zero matches, while the module + // demonstrably has advisories (asserted above) — the filter filters. + let s = tool_payload(&call_tool( + "query", + json!({"module_path": p, "code": "use-after-drop"}), + )); + assert_eq!(s["total_matches"], 0); + assert_eq!(s["matches"].as_array().unwrap().len(), 0); + + // Unconstrained query selects everything (Query::default semantics). + let s = tool_payload(&call_tool("query", json!({"module_path": p}))); + assert_eq!(s["total_matches"], n_all); + } + + #[test] + fn query_limit_caps_matches_but_reports_total() { + let truth = ground_truth(); + let n_all = truth.advisories.len(); + assert!( + n_all > 1, + "fixture must have more advisories than the limit" + ); + let p = fixture_path("limit"); + let s = tool_payload(&call_tool("query", json!({"module_path": p, "limit": 1}))); + assert_eq!(s["matches"].as_array().unwrap().len(), 1); + assert_eq!(s["total_matches"], n_all); + assert_eq!(s["truncated"], true); + } + + #[test] + fn query_rejects_an_unknown_class_value() { + let p = fixture_path("badclass"); + let resp = call_tool("query", json!({"module_path": p, "class": "no-such-class"})); + assert_eq!(resp["error"]["code"], -32602, "got: {resp}"); + } + + // ── protocol plumbing ───────────────────────────────────────────────── + + #[test] + fn parse_error_unknown_method_and_notification() { + // Malformed JSON → -32700 with a null id. + let resp: Value = roundtrip("{not json"); + assert_eq!(resp["error"]["code"], -32700); + assert!(resp["id"].is_null()); + + // Unknown method with an id → -32601. + let resp = roundtrip(&rpc(9, "no/such/method", json!({}))); + assert_eq!(resp["error"]["code"], -32601); + assert_eq!(resp["id"], 9); + + // A notification (no id) gets NO response — MCP clients send + // notifications/initialized and a reply would corrupt the stream. + let note = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(); + assert_eq!(handle_line(¬e), None); + } +} diff --git a/crates/scry-mcp/src/main.rs b/crates/scry-mcp/src/main.rs new file mode 100644 index 0000000..66e158d --- /dev/null +++ b/crates/scry-mcp/src/main.rs @@ -0,0 +1,37 @@ +//! `scry-mcp` binary — MCP server over stdio (FEAT-066). +//! +//! Reads newline-delimited JSON-RPC 2.0 messages from stdin, writes one +//! response line per request to stdout (notifications get no response). +//! All logging goes to stderr: stdout is the protocol stream and a stray +//! line there corrupts it. +//! +//! Wire up in an MCP client config as a stdio server, e.g.: +//! `{ "command": "scry-mcp" }` — then call the `analyze` / `query` tools. + +use std::io::{BufRead, Write}; + +fn main() { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("scry-mcp: stdin read error: {e}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + if let Some(resp) = scry_mcp::handle_line(&line) { + // A write/flush failure means the client hung up; exit quietly. + if writeln!(stdout, "{resp}") + .and_then(|()| stdout.flush()) + .is_err() + { + break; + } + } + } +} diff --git a/scripts/publish.rs b/scripts/publish.rs index f0d64b4..d391b0b 100644 --- a/scripts/publish.rs +++ b/scripts/publish.rs @@ -55,6 +55,9 @@ const CRATES_TO_PUBLISH: &[(&str, &str)] = &[ // Depends on scry-sai-core — publish last. The `scry-viz` binary/library // (FEAT-024) consumes the analyzer library to render an AnalysisResult. ("scry-viz", "scry-sai-viz"), + // Depends on scry-sai-core — publish after it. The `scry-mcp` MCP server + // (FEAT-066) exposes analyze/query over JSON-RPC stdio to AI agents. + ("scry-mcp", "scry-sai-mcp"), ]; struct Krate { From 562e342f856b2e0dbe43d5c97cae3f9a73ec52d3 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 28 Aug 2026 01:36:02 +0200 Subject: [PATCH 2/2] FEAT-066 review: record that cargo package is not a mid-cycle check Reviewed the delegated implementation rather than accepting its report, and independently re-ran the two load-bearing claims. AC2's structural guard WORKS -- but my first mutant did not prove it. Appending a duplicate "name" line inside the query object APPLIED (count asserted) and COMPILED (0 errors) and changed nothing, because JSON keeps the last key, so no third tool ever appeared and the test passed. Mutating properly -- a whole third tool object appended to the json! array -- turns it red with left: ["analyze", "query", "verify"] So the mutation ladder has a third rung: APPLIED, then COMPILED, then ACTUALLY CREATES THE CONDITION. Only the third proves anything, and all three look alike in the output. Stopping at rung two would have had me report a correct test as ineffective. RELEASE-MACHINERY NOTE, verified and not introduced here: `cargo package -p scry-sai-mcp` fails verify against crates.io scry-sai-core 3.2.7, which predates the Query API. Confirmed `scry-sai-viz` ON MAIN fails identically (it uses FEAT-065's VerifyReport, also absent from 3.2.7). Pre-existing mid-cycle condition affecting any crate consuming an unreleased core API; it resolves at release via the version bump plus leaf-before-core publish order. The consequence worth writing down: `cargo package` is NOT a valid pre-release check mid-cycle. It fails for a correct tree, so a release runner reaching for it as a smoke test gets a red that means nothing. tests=0 clippy=0 fmt=0 rivet=0 claim-check=0 gate-coverage=0 drift-gate=0. Refs: FEAT-066 Claude-Session: https://claude.ai/code/session_01KkNzkNYzPh7366DkNijeNc Co-authored-by: Claude Opus 5 --- artifacts/roadmap-3.0.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/artifacts/roadmap-3.0.yaml b/artifacts/roadmap-3.0.yaml index d330395..f2c2931 100644 --- a/artifacts/roadmap-3.0.yaml +++ b/artifacts/roadmap-3.0.yaml @@ -1223,6 +1223,21 @@ artifacts: The `verify` tool follows FEAT-065 into v3.4.0 with REQ-021 — exposing a refuted adjudicator over MCP would put a wrong verdict directly into an agent's tool loop, which is the single worst place for it to land. + + RELEASE-MACHINERY NOTE, verified 2026-08-27 and NOT introduced by this + feature: `cargo package -p scry-sai-mcp` fails its verify step against + the crates.io `scry-sai-core` 3.2.7, which predates the `Query` API + (FEAT-067, #188). MEASURED that `scry-sai-viz` ON MAIN fails identically + and for the same reason — it uses FEAT-065's `VerifyReport`, also absent + from 3.2.7. So this is a PRE-EXISTING MID-CYCLE condition affecting every + crate that consumes an unreleased scry-sai-core API, and it resolves at + release time via the version bump plus leaf-before-core publish order. + The consequence worth writing down: `cargo package` is NOT a valid + pre-release check mid-cycle. It will fail for a correct tree, so a + release runner who reaches for it as a smoke test gets a red that means + nothing. The publish path already handles the ordering (scripts/ + publish.rs); nothing needs fixing, but nothing should be diagnosed from + that failure either. tags: [ai-agent, mcp, interop, v3.3] fields: phase: phase-3