Skip to content

Commit 6c07c3d

Browse files
Claude/echidna mcp (#33)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5ee691 commit 6c07c3d

14 files changed

Lines changed: 804 additions & 95 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# ECHIDNA MCP Container — exposes ECHIDNA as a Model Context Protocol server
5+
# over stdio so any MCP-capable AI agent (Claude Code, Claude Desktop, Claude
6+
# API with tool use, other clients) can call `prove({file, prover, timeout_secs})`
7+
# and get a real solver verdict.
8+
#
9+
# Bundled MVP prover set: Z3, CVC5, Coq, Agda, Metamath, SPASS (all apt), plus
10+
# Lean 4 (via elan), Isabelle, F*, and Mizar where upstream distributions
11+
# cooperate with an unprivileged build environment.
12+
#
13+
# Build: podman build -f .containerization/Containerfile.mcp -t echidna:mcp .
14+
# Run: podman run --rm -i echidna:mcp # stdio MCP transport
15+
# Use: {"mcpServers":{"echidna":{"command":"podman",
16+
# "args":["run","--rm","-i","echidna:mcp"]}}}
17+
18+
# =============================================================================
19+
# Stage 1: Rust builder — echidna + echidna-mcp + echidna-wire + typed_wasm
20+
# =============================================================================
21+
FROM docker.io/library/rust:1.85-slim-bookworm AS rust-builder
22+
23+
RUN apt-get update && apt-get install -y --no-install-recommends \
24+
build-essential \
25+
pkg-config \
26+
libssl-dev \
27+
capnproto \
28+
&& rm -rf /var/lib/apt/lists/*
29+
30+
WORKDIR /build
31+
32+
COPY Cargo.toml Cargo.lock ./
33+
COPY src/rust ./src/rust
34+
COPY src/interfaces ./src/interfaces
35+
COPY crates ./crates
36+
COPY benches ./benches
37+
38+
# Build both the CLI (echidna) and the MCP server (echidna-mcp). The MCP
39+
# server shells out to the CLI via the ECHIDNA_BIN env var.
40+
RUN cargo build --release --bin echidna -p echidna-mcp
41+
42+
# =============================================================================
43+
# Stage 2: MVP prover installer (Debian apt-available set)
44+
# =============================================================================
45+
FROM docker.io/library/debian:bookworm-slim AS mvp-installer
46+
47+
RUN apt-get update && apt-get install -y --no-install-recommends \
48+
z3 \
49+
cvc5 \
50+
coq \
51+
agda-bin \
52+
metamath \
53+
spass \
54+
&& rm -rf /var/lib/apt/lists/*
55+
56+
# =============================================================================
57+
# Stage 3: Lean 4 installer (elan + stable toolchain)
58+
# =============================================================================
59+
FROM docker.io/library/debian:bookworm-slim AS lean-installer
60+
RUN apt-get update && apt-get install -y --no-install-recommends \
61+
curl ca-certificates bash \
62+
&& rm -rf /var/lib/apt/lists/*
63+
RUN curl -fsSL https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh \
64+
| bash -s -- -y --default-toolchain stable \
65+
&& . "$HOME/.elan/env" \
66+
&& lean --version
67+
68+
# =============================================================================
69+
# Stage 4: Isabelle 2025 installer (tarball + pre-built Main heap)
70+
# =============================================================================
71+
FROM docker.io/library/debian:bookworm-slim AS isabelle-installer
72+
RUN apt-get update && apt-get install -y --no-install-recommends \
73+
curl ca-certificates perl fontconfig \
74+
&& rm -rf /var/lib/apt/lists/*
75+
RUN curl -fsSL --retry 3 \
76+
"https://isabelle.in.tum.de/dist/Isabelle2025-2_linux.tar.gz" \
77+
-o /tmp/isabelle.tar.gz \
78+
&& tar -xzf /tmp/isabelle.tar.gz -C /opt/ \
79+
&& rm /tmp/isabelle.tar.gz \
80+
&& rm -rf /opt/Isabelle2025-2/doc \
81+
&& /opt/Isabelle2025-2/bin/isabelle version \
82+
&& /opt/Isabelle2025-2/bin/isabelle build -b Main \
83+
&& rm -rf /root/.isabelle/Isabelle2025-2/log \
84+
/root/.isabelle/Isabelle2025-2/browser_info
85+
86+
# =============================================================================
87+
# Stage 5: F* installer (binary release)
88+
# =============================================================================
89+
FROM docker.io/library/debian:bookworm-slim AS fstar-installer
90+
RUN apt-get update && apt-get install -y --no-install-recommends \
91+
curl ca-certificates jq tar gzip \
92+
&& rm -rf /var/lib/apt/lists/*
93+
# Resolve the latest F* Linux x86_64 release tarball via the GitHub API
94+
# (asset naming varies between releases; don't hard-code a version).
95+
RUN set -eu; \
96+
url=$(curl -fsSL https://api.github.com/repos/FStarLang/FStar/releases/latest \
97+
| jq -r '.assets[]|select(.name|test("Linux|linux.*x86_64"))|.browser_download_url' \
98+
| head -1); \
99+
if [ -n "$url" ]; then \
100+
curl -fsSL "$url" -o /tmp/fstar.tar.gz \
101+
&& mkdir -p /opt/fstar \
102+
&& tar -xzf /tmp/fstar.tar.gz -C /opt/fstar --strip-components=1 \
103+
&& /opt/fstar/bin/fstar.exe --version || true; \
104+
else \
105+
mkdir -p /opt/fstar/bin; \
106+
echo "WARN: no F* linux asset found; stage empty" >&2; \
107+
fi
108+
109+
# =============================================================================
110+
# Stage 6: Runtime image
111+
# =============================================================================
112+
FROM docker.io/library/debian:bookworm-slim
113+
114+
LABEL maintainer="Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>"
115+
LABEL org.opencontainers.image.source="https://github.com/hyperpolymath/echidna"
116+
LABEL org.opencontainers.image.description="ECHIDNA MCP server — AI-agent-callable theorem proving over stdio"
117+
LABEL org.opencontainers.image.licenses="PMPL-1.0-or-later"
118+
LABEL org.opencontainers.image.version="2.1.0"
119+
120+
# Runtime deps: the MVP provers + dynamic libs + perl/fontconfig for Isabelle.
121+
RUN apt-get update && apt-get install -y --no-install-recommends \
122+
ca-certificates \
123+
libssl3 \
124+
libgmp10 \
125+
libstdc++6 \
126+
perl \
127+
fontconfig \
128+
z3 \
129+
cvc5 \
130+
coq \
131+
agda-bin \
132+
metamath \
133+
spass \
134+
&& rm -rf /var/lib/apt/lists/*
135+
136+
WORKDIR /app
137+
138+
COPY --from=rust-builder /build/target/release/echidna /app/bin/echidna
139+
COPY --from=rust-builder /build/target/release/echidna-mcp /app/bin/echidna-mcp
140+
COPY --from=lean-installer /root/.elan /opt/elan
141+
COPY --from=isabelle-installer /opt/Isabelle2025-2 /opt/Isabelle2025-2
142+
COPY --from=isabelle-installer /root/.isabelle /root/.isabelle
143+
COPY --from=fstar-installer /opt/fstar /opt/fstar
144+
145+
ENV PATH="/app/bin:/opt/elan/toolchains/stable/bin:/opt/Isabelle2025-2/bin:/opt/fstar/bin:${PATH}"
146+
ENV ECHIDNA_BIN="/app/bin/echidna"
147+
148+
# Stdio transport — no ports. The client connects via podman run -i.
149+
ENTRYPOINT ["/app/bin/echidna-mcp"]

src/rust/provers/abc.rs

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -414,20 +414,34 @@ impl ProverBackend for AbcBackend {
414414
.context("Failed to read ABC input file")?;
415415

416416
let extension = path.extension().and_then(|e| e.to_str());
417-
self.parse_design(&content, extension)
417+
let mut state = self.parse_design(&content, extension)?;
418+
state.metadata.insert(
419+
"abc_source".to_string(),
420+
serde_json::Value::String(content.clone()),
421+
);
422+
state.metadata.insert(
423+
"source_path".to_string(),
424+
serde_json::Value::String(path.to_string_lossy().into_owned()),
425+
);
426+
Ok(state)
418427
}
419428

420429
async fn parse_string(&self, content: &str) -> Result<ProofState> {
421430
// Without file extension context, try ABC command script first,
422431
// then fall back to AIGER ASCII if header starts with "aag"/"aig"
423432
let trimmed = content.trim();
424-
if trimmed.starts_with("aag") || trimmed.starts_with("aig") {
425-
self.parse_design(content, Some("aig"))
433+
let mut state = if trimmed.starts_with("aag") || trimmed.starts_with("aig") {
434+
self.parse_design(content, Some("aig"))?
426435
} else if trimmed.starts_with(".model") || trimmed.starts_with(".inputs") {
427-
self.parse_design(content, Some("blif"))
436+
self.parse_design(content, Some("blif"))?
428437
} else {
429-
self.parse_design(content, None)
430-
}
438+
self.parse_design(content, None)?
439+
};
440+
state.metadata.insert(
441+
"abc_source".to_string(),
442+
serde_json::Value::String(content.to_string()),
443+
);
444+
Ok(state)
431445
}
432446

433447
async fn apply_tactic(&self, state: &ProofState, tactic: &Tactic) -> Result<TacticResult> {
@@ -567,6 +581,131 @@ impl ProverBackend for AbcBackend {
567581
}
568582

569583
async fn verify_proof(&self, state: &ProofState) -> Result<bool> {
584+
// Prefer the original source — parsing ABC/BLIF/AIGER into the
585+
// generic Term IR and reconstructing via `to_abc_script` loses
586+
// structure for anything beyond a one-command script.
587+
let method = state
588+
.metadata
589+
.get("abc_method")
590+
.and_then(|v| v.as_str())
591+
.unwrap_or("pdr");
592+
let bmc_bound = state
593+
.metadata
594+
.get("bmc_bound")
595+
.and_then(|v| v.as_str())
596+
.unwrap_or("100");
597+
let verify_cmd = match method {
598+
"bmc" | "bmc3" => format!("bmc3 -F {}", bmc_bound),
599+
"int" => "int".to_string(),
600+
"dprove" => "dprove".to_string(),
601+
"dsec" => "dsec".to_string(),
602+
"cec" => "cec".to_string(),
603+
_ => "pdr".to_string(),
604+
};
605+
606+
if let Some(path) = state.metadata.get("source_path").and_then(|v| v.as_str()) {
607+
let p = std::path::Path::new(path);
608+
let read_cmd = match p.extension().and_then(|e| e.to_str()) {
609+
Some("aig") => format!("read_aiger {}", path),
610+
Some("blif") => format!("read_blif {}", path),
611+
Some("v") => format!("read_verilog {}", path),
612+
_ => format!("read {}", path),
613+
};
614+
let script = format!("{}\n{}\nprint_status\nquit\n", read_cmd, verify_cmd);
615+
let tmp_dir =
616+
tempfile::tempdir().context("Failed to create temporary directory for ABC")?;
617+
let tmp_file = tmp_dir.path().join("verify.abc");
618+
tokio::fs::write(&tmp_file, &script)
619+
.await
620+
.context("Failed to write temporary ABC script file")?;
621+
let output = tokio::time::timeout(
622+
tokio::time::Duration::from_secs(self.config.timeout),
623+
Command::new(&self.config.executable)
624+
.arg("-f")
625+
.arg(&tmp_file)
626+
.stdout(Stdio::piped())
627+
.stderr(Stdio::piped())
628+
.output(),
629+
)
630+
.await
631+
.map_err(|_| anyhow!("ABC timed out after {} seconds", self.config.timeout))?
632+
.context("Failed to execute ABC")?;
633+
let stdout = String::from_utf8_lossy(&output.stdout);
634+
let stderr = String::from_utf8_lossy(&output.stderr);
635+
let combined = format!("{}\n{}", stdout, stderr);
636+
return self.parse_result(&combined);
637+
}
638+
639+
if let Some(source) = state.metadata.get("abc_source").and_then(|v| v.as_str()) {
640+
let trimmed = source.trim();
641+
let (ext, read_cmd_base) =
642+
if trimmed.starts_with("aag") || trimmed.starts_with("aig") {
643+
(".aig", "read_aiger")
644+
} else if trimmed.starts_with(".model") || trimmed.starts_with(".inputs") {
645+
(".blif", "read_blif")
646+
} else {
647+
// Inline script — just execute it directly.
648+
("", "")
649+
};
650+
let tmp_dir =
651+
tempfile::tempdir().context("Failed to create temporary directory for ABC")?;
652+
if ext.is_empty() {
653+
let tmp_file = tmp_dir.path().join("verify.abc");
654+
let script = format!("{}\n{}\nprint_status\nquit\n", source, verify_cmd);
655+
tokio::fs::write(&tmp_file, &script)
656+
.await
657+
.context("Failed to write temporary ABC script file")?;
658+
let output = tokio::time::timeout(
659+
tokio::time::Duration::from_secs(self.config.timeout),
660+
Command::new(&self.config.executable)
661+
.arg("-f")
662+
.arg(&tmp_file)
663+
.stdout(Stdio::piped())
664+
.stderr(Stdio::piped())
665+
.output(),
666+
)
667+
.await
668+
.map_err(|_| anyhow!("ABC timed out after {} seconds", self.config.timeout))?
669+
.context("Failed to execute ABC")?;
670+
let stdout = String::from_utf8_lossy(&output.stdout);
671+
let stderr = String::from_utf8_lossy(&output.stderr);
672+
let combined = format!("{}\n{}", stdout, stderr);
673+
return self.parse_result(&combined);
674+
} else {
675+
let design_file = tmp_dir.path().join(format!("design{}", ext));
676+
tokio::fs::write(&design_file, source)
677+
.await
678+
.context("Failed to write temporary ABC design file")?;
679+
let script_file = tmp_dir.path().join("verify.abc");
680+
let script = format!(
681+
"{} {}\n{}\nprint_status\nquit\n",
682+
read_cmd_base,
683+
design_file.display(),
684+
verify_cmd
685+
);
686+
tokio::fs::write(&script_file, &script)
687+
.await
688+
.context("Failed to write temporary ABC script file")?;
689+
let output = tokio::time::timeout(
690+
tokio::time::Duration::from_secs(self.config.timeout),
691+
Command::new(&self.config.executable)
692+
.arg("-f")
693+
.arg(&script_file)
694+
.stdout(Stdio::piped())
695+
.stderr(Stdio::piped())
696+
.output(),
697+
)
698+
.await
699+
.map_err(|_| anyhow!("ABC timed out after {} seconds", self.config.timeout))?
700+
.context("Failed to execute ABC")?;
701+
let stdout = String::from_utf8_lossy(&output.stdout);
702+
let stderr = String::from_utf8_lossy(&output.stderr);
703+
let combined = format!("{}\n{}", stdout, stderr);
704+
return self.parse_result(&combined);
705+
}
706+
}
707+
708+
// Fallback: reconstruct an ABC script from the parsed IR.
570709
let abc_script = self.to_abc_script(state)?;
571710

572711
// ABC can execute commands via -c flag or from a script file.

0 commit comments

Comments
 (0)