An MCP server that gives an AI agent Frama-C: EVA abstract interpretation, WP deductive proof, ACSL annotation injection, and isolated sandboxes for trying annotations out.
The server is designed to be driven by an agent rather than by a person. An MCP client spawns it and speaks to it over stdio, so there is no command for a human to type. It is built around an iterative loop: propose an annotation, prove it, read why the goal did not close, then revise. The session state follows from that loop rather than from shell use, since one project stays loaded across calls, sandboxes are addressed by name, and proof receipts compare only within a run.
Wire it into Claude Code, Claude Desktop, or any MCP client, then prompt in
English. See Connect an agent and
Prompt patterns. A check subcommand exists for CI and is
the only part intended to be run by hand.
┌──────────┐ MCP over stdio ┌────────────────┐ Unix socket ┌─────────────────┐
│ AI agent │◄─────────────────►│ frama-c-mcp │◄─────────────►│ Frama-C main │
└──────────┘ │ Rust server │ │ + ast-utils │
└───────┬────────┘ └─────────────────┘
│ Unix socket per sandbox
▼
┌──────────────────┐
│ Frama-C sandbox │
│ + ast-utils │
│ per experiment │
└──────────────────┘
The server has two required components:
- Rust MCP server: exposes the tools over MCP stdio, translates requests into Frama-C's server protocol, and lazily starts the main Frama-C process on the first project operation.
ast-utilsFrama-C plugin: provides the custom requests for AST access, dependency extraction, ACSL injection, sandboxing, and WP configuration. Build and install it in the same opam switch as Frama-C.
create_sandbox extracts a function together with its type, callee, and global
dependencies into a temporary C file, then starts a separate Frama-C process.
An agent can iterate on annotations there without mutating the main project;
verified sandbox annotations are merged back explicitly.
The server holds one project: reload_project or the first check loads it,
and later calls operate on that AST. Sandboxes are separate processes,
addressed as experiment_id:function.
The preprocessor surface is include_paths, defines, force_includes, and
machdep, applied in that order. Each value is written without its compiler
flag.
Build the Frama-C plugin, build and install the server, then point an MCP client at it. The plugin and Frama-C must be installed in the same opam switch.
- Frama-C 33.0 (the version exercised by CI)
- OCaml 4.14.2, opam, and dune >= 3.0
- Rust 2021 toolchain
- WP provers: Alt-Ergo, with Z3 and CVC5 optional
eval $(opam env --switch=frama-c-33)
cd ast-utils
dune build && dune install
cd ..
cargo build --releasedune resolves Frama-C through the opam switch, not through PATH. Setting
only PATH builds the plugin against whichever switch is active and fails with
unbound modules in files you did not touch.
After changing the plugin, run dune clean && dune build && dune install.
Incremental builds may not relink the installed .cmxs.
Every push to main that passes the full test lane replaces a rolling
latest release, so the download link never moves:
# Linux x86_64
curl -sSfL -o frama-c-mcp.tar.gz \
https://github.com/sysprog21/frama-c-mcp/releases/download/latest/frama-c-mcp-x86_64-unknown-linux-gnu.tar.gz
# macOS arm64
curl -sSfL -o frama-c-mcp.tar.gz \
https://github.com/sysprog21/frama-c-mcp/releases/download/latest/frama-c-mcp-aarch64-apple-darwin.tar.gz
tar xzf frama-c-mcp.tar.gz
mkdir -p ~/.local/bin
install -m 755 frama-c-mcp ~/.local/bin/There is no Windows build: the transport speaks Unix sockets and Frama-C does not target Windows either.
The tarball holds the Rust MCP server and nothing else. It is half the product:
most tools return invalid without the ast-utils plugin, and that plugin
still has to be built from source in the opam switch holding your Frama-C,
because a .cmxs is tied to the exact OCaml and Frama-C that compiled it. So a
download is dune build && dune install under Build plus this binary
in place of cargo build --release, not a way to skip the switch.
./scripts/install.sh # to ~/.local/bin
BINDIR=/usr/local/bin ./scripts/install.shThe script builds and installs both the Rust binary and the ast-utils plugin.
It runs dune through opam exec, so the plugin lands in the active switch;
select the switch holding Frama-C before running it. The install fails if the
resulting plugin is not loadable by that switch's frama-c. BINDIR must be
writable without sudo, which would discard the opam environment.
Use the script instead of copying over an existing binary. On macOS, replacing
an executed binary in place can leave a stale code-signature blob and cause
every subsequent execution to fail with SIGKILL (Code Signature Invalid).
The script installs through a temporary file, ad-hoc signs it, runs it once,
and then renames it into place.
Declare the server in .mcp.json for a project, or in
claude_desktop_config.json for Claude Desktop:
{
"mcpServers": {
"frama-c": {
"command": "/path/to/frama-c-mcp",
"args": ["--frama-c", "/path/to/frama-c"]
}
}
}The client spawns that command and speaks MCP over its stdin and stdout, so
there is nothing to start beforehand; run the binary from a terminal and it
simply waits on stdin. Frama-C itself is spawned lazily, on the agent's first
project operation, which is why a misconfigured --frama-c surfaces on the
first check rather than at startup.
The arguments the client passes:
| Flag | Default | Description |
|---|---|---|
--frama-c |
frama-c |
Path to the Frama-C binary |
--max-sandboxes |
32 |
Maximum concurrent sandbox Frama-C processes |
--socket |
none | Deprecated; ignored when set because sockets are generated per process |
Once connected, drive it in English rather than by naming tools; the agent picks the calls. Prompt patterns is the table of what to say.
The check subcommand runs the same code path as the check tool and prints
its JSON payload, so a pipeline can use the server without an agent and without
speaking MCP. It is the one entry point meant to be typed:
./target/release/frama-c-mcp check src/foo.c --function foo \
-I include -D NDEBUG --force-include builtins.h --require-complete--require-complete exits non-zero when incomplete[] is non-empty, which is
the difference between "nothing was reported" and "everything was checked".
The rest of the tool surface has no CLI form, on purpose: it is stateful across
calls and there is no session to hold that state in from a shell.
Say what evidence you want back, and hand over what the server cannot infer: the include paths, the defines, and which function is the target. The left column is what you type; the middle is what a well-behaved agent does with it; the right is the trap that makes the obvious call the wrong one.
| Say | Call | Trap |
|---|---|---|
"Check foo.c for runtime errors and prove its contracts; it builds with -Iinclude -DNDEBUG. Target bar. Tell me what was left unchecked." |
check({files:["foo.c"], function:"bar", include_paths:["include"], defines:["NDEBUG"]}) |
check reloads the project, so it needs the real build flags. Without them Frama-C parses a different program, or none |
| "Give me every goal and alarm, not the first few." | check({..., detail:"full"}) |
The default is summary. full runs to hundreds of kilobytes on a real file, and verdict and incomplete[] are computed from the complete data either way |
| "Frama-C cannot parse this. Reproduce the compiler configuration; do not delete code to make it parse." | reload_project({files, include_paths, defines, force_includes, machdep}) |
force_includes supplies declarations Frama-C lacks. A define that erases the call site parses, and then proves the wrong program |
"What can n hold at foo.c:42?" |
context({want:["marker_at"], file:"foo.c", line:42}) → context({want:["eva_value"], marker}) |
Needs EVA to have run. Which marker comes back follows the position, not the intent: a declaration line answers with the variable's #v, so read marker_kind before passing it on |
| "Why might line 42 overflow? Show the values, the callers, and the annotations in force." | get_wp_goals({want:["alarms"]}) → get_wp_goals({want:["investigation"], marker:"#p10", depth:"deep"}) |
investigation takes the property marker off the alarm row. depth defaults to normal, which omits the annotations |
"List everything this run did not establish for bar." |
get_wp_goals({want:["alarms","goals"], function:"bar", status:"unproved"}) |
unproved is every status other than valid. A status that is neither a Frama-C name nor one this run produced is an error, so a typo cannot read as proved |
| "Why is that goal not proved? Show the hypotheses WP had." | get_wp_goals({want:["vc"], function:"bar"}) |
The sequent is per function, and vc requires function |
"Show me bar as Frama-C sees it, with my contract read back." |
context({want:["contract_context","loop_effects"], function:"bar"}) |
The read-back route for annotations you injected: type-checked, macros expanded, as WP will use them |
| "I have no annotations yet. What does the code itself determine?" | propose_annotations({function:"bar"}) → inject_all_annotations({function:"bar", dry_run:true, annotations:[...]}) |
Frames only, each already type-checked against the AST. The predicates that make a proof go through are under not_proposed, named rather than guessed |
| "Type-check this ACSL against the real AST but change nothing." | inject_all_annotations({function:"bar", dry_run:true, annotations:[...]}) |
Invariants, asserts, assigns, ghost code and lemmas all inject on main. requires and ensures are the exception, refused there whatever dry_run says |
"Try requiring n >= 0 without touching the main project." |
create_sandbox({function:"bar", experiment_id:"exp42"}) → inject_all_annotations({sandbox_name:"exp42:bar", annotations:[...]}) → run_wp({functions:["exp42:bar"]}) |
Contracts belong to whoever reviews the source, so main refuses them and the sandbox is where one gets tried. Merge back explicitly |
"Prove bar now, and tell me what my last change actually bought." |
run_wp({functions:["bar"], cache:"None"}) → get_wp_goals({since:"<earlier receipt sha256>"}) |
-wp-cache defaults to update, so a valid verdict may be replayed rather than computed. since only names receipts from this session |
"Prove the ready functions, one bounded batch at a time, then record what bar assumed." |
run_wp({functions:["bar"], retry_unproved:true}) → store_function_conclusion({function:"bar", status:"verified", proof_receipt, wp_summary}) → list({kind:"conclusions", function:"bar"}) |
WP's budget is per call, so an oversized batch returns nothing at all. verified is refused unless the receipt's goals are all valid and their count matches wp_summary |
| "Verify the file bottom up and tell me what to do next." | verify_program_step({lock_project:false}) |
The lock defaults on and blocks every later run_wp on main |
| "Does it actually break when it runs?" | run_e_acsl({use_current_ast:true, args:[...]}) |
Compiles and executes the code with your privileges; trusted source only. Without use_current_ast it runs the files on disk, which do not carry this session's annotations |
| "Write out the annotated source." | context({want:["source"], output:"out/annotated.c"}) |
Whole program, every contract and generated RTE assert. The path must stay inside the working directory |
| "Why did that tool fail?" | self_check({canary:true}) |
Versions, provers, which requests answer. canary adds about 30s proving the backend still tells a bundled bug from its fix |
The WP memory model is process state. Frama-C takes some changes within one
process and not others, so a change that aborts reports what it changed from
and points at reload_project.
Ask for a verdict rather than a summary. check reports proved only when
incomplete[] is empty, so "no alarms were reported" and "everything was
checked" stay distinguishable. See docs/agent-playbook.md
for the shortest reliable call order for each workflow, and
docs/writing-acsl.md for what to write when a goal will
not close, keyed to the finding categories and incomplete[] codes.
The server exposes the following tool groups:
| Domain | Tools | Purpose |
|---|---|---|
| Project | reload_project, list, context, self_check |
Load source, inspect declarations, navigate call relationships, and report server capabilities |
| EVA/WP | check, run_wp, get_wp_goals, run_e_acsl |
Run verification, read what it concluded, and execute runtime counterexamples |
| Annotations | inject_all_annotations, propose_annotations |
Dry-run validate and inject ACSL annotations, and propose the frame conditions the code determines |
| Sandbox | create_sandbox, delete_sandbox |
Isolate annotation experiments |
| Orchestration | verify_program_step |
Run bottom-up verification steps |
| State | store_function_conclusion |
Persist verification conclusions |
run_e_acslcompiles the loaded source and runs the resulting binary with your privileges. Every other tool only analyzes. Do not point it at C source you do not trust, and note that an agent reading untrusted source can be steered into calling it.
The rest of this section covers the parameters whose behavior is not obvious from the tool schema.
Identifiers that become directory names, store_function_conclusion {function}
and create_sandbox {experiment_id}, are restricted to [A-Za-z0-9_-] so they
cannot escape .frama-c-mcp/ or the sandbox root.
context {want: ["source"], output} is the only tool that writes a file the
caller names, and the path must resolve inside the working directory. An
absolute path elsewhere, a .. that climbs out, and a symlink inside the tree
that points out of it are all refused.
run_e_acsl {tool} names the E-ACSL wrapper, not an arbitrary executable: it
must be e-acsl-gcc or e-acsl-gcc.sh, resolved through PATH. Both names
exist because installs differ.
reload_project {include_paths, defines, force_includes} become preprocessor
flags, and Frama-C hands those to a shell (its -cpp-extra-args is "unsafe in
sandbox mode"). Each entry is therefore restricted to [A-Za-z0-9_./+-], plus
= for defines, with no leading dash, so a value cannot carry a command
substitution or the ${IFS} space trick into that shell. A define needing a
shell-active character (a parenthesized expression, a quoted string) is refused;
force_includes a header that spells it instead.
list accepts a kind of files, functions, globals, declarations,
sandboxes, or conclusions. For conclusions, status filters the summaries
and function returns one full conclusion.
run_wp accepts smoke: true together with provers to run isolated CLI
smoke tests.
self_check reports tool_surface: the tool count, the byte size of the
tools/list result that is resent on every agent turn, and the three heaviest
tools. Computed from the running server, so it cannot be quoted stale.
It parses the frama-c -version banner rather than only checking that
the command exited zero. frama_c.major, frama_c.supported and
frama_c.minimum_major report whether the installed version meets this
server's minimum supported major version, and frama_c.unsupported_reason
names the mismatch when it does not. An older Frama-C exits zero like any other,
and the failure it causes lands somewhere with no version in it: a plugin that
will not load, or a request answered invalid.
capabilities.known_frama_c_version_limitations repeats the reason and is
derived from the same probe, so it cannot go stale against the version actually
installed.
self_check also accepts canary: true. The request probes report which
requests answer; they cannot report whether EVA and WP still catch anything, and
an install where every request answers and no alarm is ever raised passes them
while being useless. The canary runs tests/fixtures/abs-int-buggy.c and its
fixed twin through check and judges the reason rather than the verdict: the
buggy file must report an ALARM_NOT_VALID naming signed_overflow, and the
fixed one must be proved with an empty incomplete[]. The pair is the test,
not either half: with WP dead the buggy file still reports its alarm from EVA,
and it is the fixed file that catches it.
The canary is off by default because it is two full EVA and WP runs, about 30
seconds, and it performs them in a separate Frama-C process with its own state.
check reloads whatever project it runs against, so a canary sharing the
session would discard the loaded AST and every annotation injected into it.
get_wp_goals reads the one property table every analysis writes to, selected by
a want array in the idiom context uses: goals (the default, WP proof
goals, filtered by function and status, or diffed against an earlier run
with since), alarms (EVA alarms, filtered by function, alarm_kind or
status), counts (property counts plus EVA and WP state), vc (one
function's verification condition as a sequent), and investigation (one
property joined to its value ranges, callers, and annotations, keyed on
marker and taking depth).
Each of alarm_kind, marker/depth/callstack, and since belongs to one
want and is rejected without it, so a call cannot quietly get an answer that
ignored what it passed. status is the one parameter two wants read, and it
means the same thing on both: unproved selects everything not valid, whether
the rows are goals or alarms.
inject_all_annotations takes every clause in one annotations array, each entry
tagged with a kind of global, behavior, requires, ensures, assigns, assert,
loop, complete_behaviors, disjoint_behaviors, terminates, exits, or decreases.
Generated clauses get readable labels by default, so WP goals trace back to the
entry that produced them, and diagnostics name the failing annotations[i].
The same array carries ghost code: ghost_global, ghost_formal,
ghost_lemma_function, ghost_loop, and ghost_stmt, each with its fields on
the entry rather than nested under a spec. Ghost entries
are applied before clause entries in one call, because a ghost formal changes
the signature a requires refers to, and the clause plan is skipped entirely
if any ghost fails. Each one answers under ghosts[], which carries the
plug-in's payload verbatim: vid for a ghost global, loop_sid and sids for
a ghost loop.
ghost_global and ghost_lemma_function belong to a project rather than to a
function, so function there only selects main or which sandbox.
Under dry_run, ghost entries are checked for their kind's fields and a
resolvable target but not inserted, and the clauses are then validated against
an AST that does not carry them. The response says so with
ghosts_not_applied, because a requires naming a proposed ghost formal reads
as invalid there and may not be.
context accepts want values for function_ast, cil_context,
contract_context, logic_deps, property_context, rte_obligations,
current_annotations, write_effects, loop_effects, messages, source,
symbol, marker_at, eva_value, callgraph, callers, and call_chain.
marker_at and eva_value are the two halves of "what does this variable hold
here": marker_at turns {file, line, column?} into a statement marker, and
eva_value reads EVA's range at it. eva_value was once its own tool; it lives
here because get_wp_goals reads the property table, and a statement marker is
not in that table.
The navigation wants divide as follows. symbol takes the identifier in
function and answers for a global variable too. callgraph and call_chain
read the syntactic call graph, while callers returns EVA caller data and so
requires check to have run first. callgraph is whole-program and rejects
function when it is the only want.
Silence is not a proof, so results carry both what was found and what was actually checked.
check returns a verdict of proved only when incomplete[] is empty. Any
step that did not run, timed out, or was skipped becomes an incomplete[]
entry, and the verdict falls back to incomplete even when nothing failed.
Every entry carries a code, and only the code is frozen; see
docs/reference/result-schema.md for the
payload contract and the change rule. The full set:
| Code | Meaning |
|---|---|
RTE_DISABLED |
Ran without RTE, so no alarms does not exclude runtime errors |
EVA_NOT_RUN |
EVA did not complete, so eva_alarms proves nothing |
WP_NOT_RUN |
WP did not complete, so wp_goals proves nothing |
WP_STILL_RUNNING |
WP was working when its goals were read, so a goal may be missing entirely |
ALARM_NOT_VALID |
EVA left a generated runtime-error alarm undischarged |
GOAL_NOT_VALID |
WP has a non-valid goal |
PROVER_TIMEOUT |
A prover timed out on a goal |
PROPERTY_DEAD |
EVA proved the code unreachable, so nothing proved about it constrains a run |
PROPERTY_DISPROVED |
Frama-C disproved a property and WP emits no goal for one that already has a status |
PROPERTY_INCONSISTENT |
Frama-C consolidated contradictory statuses, so the verdict cannot be trusted |
LEMMA_NOT_PROVED |
WP assumed a lemma everywhere without discharging it |
ASSUMED_VALID |
Recorded valid by external assumption, an axiom, not by proof |
ASSUMED_CALLEE_CONTRACT |
A callee's contract was taken on faith, with no finite assigns |
UNCONSTRAINED_ASSIGNS |
The contract lists a location in assigns that no postcondition mentions, so proving the function says nothing about the value written there |
RESULT_UNCONSTRAINED |
The contract bounds \result to a small range but never ties some of those values to the inputs, so proving it does not pin down what the function returns |
UNPROVED_ASSUMPTION |
An assertion or postcondition WP could not prove, which it still hands to later goals as a hypothesis |
VALID_UNDER_HYP |
WP proved the goal, but Frama-C consolidated its property as valid only under hypotheses nothing has established |
EVA_NOT_REQUESTED |
want excluded EVA, so nothing here excludes the alarms it finds |
WP_NOT_REQUESTED |
want excluded WP, so nothing here is a proof |
Treat the set as additive: codes are added as gaps are found, and three were added in one day. Branch on the ones you handle and surface the rest rather than assuming an unknown code is benign.
check also returns messages[], Frama-C's own errors and warnings from the
run, each with its plugin and source location. A generated assigns for an
uncontracted callee is announced there and nowhere else, and it weakens every
proof above it.
check, run_wp, and stored conclusions carry a proof_receipt
(frama-c-mcp.proof-receipt.v3): the source-file hash, the Frama-C and prover
environment, the effective WP configuration, per-goal statuses, and a sha256
over all of it. Two runs are comparable exactly when their receipts match.
Each goal also reports from_cache. Frama-C's -wp-cache defaults to
update, so WP reuses verdicts it proved in earlier runs; such a verdict is a
real proof of that obligation by that prover, but not one the current run
performed, and the receipt records the difference. Pass
run_wp {cache: "None"} to prove everything in this run.
A proof is only as good as what it assumed. run_wp reports an
assumed_callee_contract finding for every callee whose contract it took on
faith instead of proving. Conclusions carry stale_dependencies when a callee's
conclusion changed underneath them and stale_proof_environment when the
prover environment moved, so a stored verified does not quietly outlive its
justification. store_function_conclusion refuses verified without a receipt,
and list {kind: "conclusions", function} returns verified_with: the memory
model, provers, timeout, assumed callee contracts, and receipt hash behind that
conclusion.
verify_program_step returns one next_action plus the unverified frontier
and any blocked_functions, under a hard payload_budget cap; when the payload
would exceed it, lists are truncated and the dropped count is reported rather
than silently omitted.
The common workflows are:
Direct EVA/WP loop:
check {files or source, function?, detail?}
# detail defaults to "summary": wp_goals and eva_alarms come back as counts
# plus the first few entries that need attention. Pass detail: "full" for every
# goal and alarm, which runs to hundreds of kilobytes on a real file. The
# verdict and incomplete[] are computed from the complete data either way.
# Or step-by-step:
check {files, want: ["eva"]} -> get_wp_goals {want: ["alarms"]}
-> get_wp_goals {want: ["investigation"], marker}
-> inject_all_annotations
-> run_wp -> get_wp_goals
Sandboxed CEGIS loop:
create_sandbox -> inject_all_annotations {dry_run: true} -> inject_all_annotations -> run_wp
-> get_wp_goals
-> inject_all_annotations with the verified annotations -> run_wp
-> store_function_conclusion -> delete_sandbox
Whole-program bottom-up loop:
reload_project -> verify_program_step
-> create_sandbox -> inject_all_annotations {dry_run: true}
-> inject_all_annotations -> run_wp -> get_wp_goals
-> store_function_conclusion
-> repeat until every function has a conclusion
Use the gate runner locally; it runs all thirteen repository checks, keeps logs
under target/gate-logs, and names failed tests. A unit test pins the runner
against the CI workflows, so the two cannot drift apart.
# Fast lane: formatting, lint, unit tests and the release build; no Frama-C needed.
scripts/run-gates.sh fast
# Full lane: requires Frama-C, WP provers, and ast-utils on PATH.
eval "$(opam env)"
scripts/run-gates.sh
# Run selected gates by name, for example:
scripts/run-gates.sh unit stdio| Suite | Needs Frama-C? | Coverage |
|---|---|---|
shfmt -i 4 -d |
no | Every tracked shell script is 4-space formatted |
cargo clippy --all-targets |
no | Lint checks for all targets, denied via [lints.clippy] |
cargo test --test unit |
no | Codec, state, callgraph, topological order, tool payload shapes |
test-store-conclusion |
no | Conclusion persistence and the on-disk long-text layout |
test-integration |
yes | Live Frama-C EVA, WP, annotations, and sandbox behavior |
test-mcp-stdio |
yes | Full MCP stdio surface |
test-process-lifecycle |
yes | Lazy spawn, SIGTERM cleanup, zombie reaping, capabilities |
test-reload-project-regression |
yes | In-place reload versus respawn |
CI is one workflow, .github/workflows/ci.yml. Four jobs run in parallel: a
fast Rust-only job on Ubuntu and macOS, an artifact scan over the tracked tree,
a full job that installs Frama-C, provers, and the ast-utils plugin before
running version smoke tests, the tutorial and abs-int fixture gates and the live
suites, and a job building the binaries above, each run on the platform it was
built for. A push to main that clears all four republishes the latest
release.
Steps whose shell is more than a couple of commands live in .ci/ and the
workflow names the path, so those scripts are formatted by the shfmt gate and
runnable by hand. The guards in tests/unit/repo-guards.rs read .ci/ as well
as .github/workflows/, since that is where the commands CI runs now are.
There is no cargo fmt gate and no rustfmt.toml, and that is a decision
rather than an oversight. The tree is about 800 hunks away from rustfmt
defaults across 40 files, because the wrapping is deliberate: payload literals
are laid out to mirror the JSON they build, and the comments that carry most of
this repository's reasoning are wrapped to be read rather than to fill a column
budget.
So do not run cargo fmt on this tree. Match the formatting of the code you
are editing, which is the same rule the shell gate enforces mechanically and
the one thing a formatter cannot check. Import ordering in particular is not a
rule here; several modules group by origin instead of alphabetically, and no
gate has an opinion.
Shell is the exception and is machine-checked, because shfmt agrees with what
the scripts already look like and there is no cost to enforcing it.
Frama-C uses a custom binary protocol, not JSON-RPC. Commands are GET,
SET, EXEC, POLL, and SHUTDOWN; SET and EXEC are queued and must be
driven with POLL.
Reparsing files requires setFiles([]), then setFiles(files), then compute.
Directly setting the same file list is a no-op in Frama-C's state dependency
system.
fetchFunctions returns a full list only after reloadFunctions; later calls
return deltas.
The server uses Typed+nocast, so casts fail safely instead of being silently
assumed away.
A bare declaration without a contract defaults to assigns \nothing, which is
unsound for many callees. Sandbox extraction emits empty-body stubs for callees
that lack explicit assigns.
frama-c-mcp is available under a permissive
MIT-style license.
Use of this source code is governed by a MIT license that can be found
in the LICENSE file.