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/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 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 {