solflow-mcp
# solflow-mcp
**SolFlow for AI agents.** An MCP server that gives agents (Claude Code, Claude Desktop, Cursor and others)
the same call-flow analysis of a Solidity repository that [SolFlow](https://github.com/norah1499/solidity-flow-navigator) shows developers in the browser, as plain text instead of a web UI.
SolFlow compiles a repo with crytic-compile, extracts facts with Slither, and builds one call tree per external
entry point. Modifiers are folded in, virtual and `super` calls are resolved through C3 linearization, and calls
it can't follow are explicitly marked. A developer explores the result as an interactive graph. An agent can't
use a canvas, so this server returns the same trees as compact text with line numbers that a model can read, quote and reason over.
- [Same analysis, two views](#same-analysis-two-views)
- [Tools](#tools)
- [Install](#install)
- [Agent skill](#agent-skill)
- [Notes](#notes)
---
## Same analysis, two views
Every example below comes from the same small project: a `Vault` with deposits, withdrawals, an owner check,
a reentrancy guard and a pluggable `IStrategy`. The project is in [`tests/fixture`](tests/fixture).
Each screenshot from SolFlow's web UI is followed by the server's real output for the same contract and function,
so you can compare them side by side.
### 1. The attack surface of `Vault`
**What a developer sees:** the `Vault` section of SolFlow's overview page. Entry points are split into writes and reads,
with modifier badges, call depth (`d2`), and a red `1 unr` badge where a flow contains a call that can't be followed.

**What the agent sees:** `list_entry_points(repo_path)`, the `Vault` section
```
## Vault (src/Vault.sol)
mutating:
adminMint(address,uint256) mods=[onlyOwner] depth=2 nodes=4
deposit(uint256) payable mods=[nonReentrant] depth=2 nodes=6
mintTwice(address,address) mods=[onlyOwner] depth=2 nodes=6
transferOwnership(address) mods=[onlyOwner] depth=1 nodes=2 (from Owned)
withdraw(uint256) mods=[nonReentrant] depth=1 nodes=5 unresolved=1
read-only:
balanceOf(address) depth=0 nodes=1 (from Token)
depth(uint256) depth=2 nodes=3
feeBps() depth=0 nodes=1
owner() depth=0 nodes=1 (from Owned)
position(address) depth=0 nodes=1
positions(address) depth=0 nodes=1
strategy() depth=0 nodes=1
totalSupply() depth=0 nodes=1 (from Token)
```
These are the same 13 entries. The modifier badges become `mods=[...]`, `d2` becomes `depth=2`,
the red `1 unr` becomes `unresolved=1`, and `(from Owned)` says where an inherited function comes from.
The full output lists every contract the same way, under the header
`19 entry points in 4 contracts. Format: signature mods=[...] depth nodes unresolved (inherited-from)`.
### 2. One flow, end to end: `Vault.withdraw`
**What a developer sees:** the expanded graph of `Vault.withdraw`, with one card of source per call. The modifier is shown
in blue, the interface call has a "resolve IStrategy → unbound" picker, and the low-level `.call` is shown in red.

**What the agent sees:** `get_flow(repo_path, "Vault.withdraw")`
```
Vault.withdraw(uint256) [external, mutating] src/Vault.sol:40-45 uses: require(bool,string)
├─ L40 modifier ReentrancyGuard.nonReentrant() src/Base.sol:20-25 uses: require(bool,string)
├─ L41 internal → Token._burn(address,uint256) src/Base.sol:37-41 uses: require(bool,string)
├─ L42 external → IStrategy.withdraw(uint256) ⚠ interface call, body unknown (no binding) src/IStrategy.sol:5
└─ L43 UNRESOLVED [low_level_call] <address>.call(...) @ src/Vault.sol:43 (low_level/call)
```
Each line is: call-site line, call kind, target, and `file:line-range`. The order of operations
(burn at L41, then the external call at L42, then ETH sent at L43) can be read straight down the tree, and every node
can be quoted in a finding. Pass `include_source=true` to see the code cards from the graph, inlined under each node:
```
├─ L41 internal → Token._burn(address,uint256) src/Base.sol:37-41 uses: require(bool,string)
│ 37 | function _burn(address from, uint256 amount) internal virtual {
│ 38 | require(balanceOf[from] >= amount, "balance");
│ 39 | balanceOf[from] -= amount;
│ 40 | totalSupply -= amount;
│ 41 | }
```
Card by card: the blue card is the `modifier` line, `Vault._burn` (inherited from Token) is the
`internal → Token._burn` line, the `IStrategy.withdraw` card with its unbound picker is the `⚠ interface call` line,
and the red `<address>.call` card is the `UNRESOLVED [low_level_call]` line.
### 3. Libraries, `super`, and dependencies: `Vault.deposit`
**What a developer sees:** `Vault.deposit` calls a library (`FeeMath.fee`), an override that calls
`super._mint`, and a dependency under `lib/` (the dashed `SafeMath2.add2` card) that is shown but not followed.

**What the agent sees:** `get_flow(repo_path, "Vault.deposit")`
```
Vault.deposit(uint256) [external, payable] src/Vault.sol:33-38
├─ L33 modifier ReentrancyGuard.nonReentrant() src/Base.sol:20-25 uses: require(bool,string)
├─ L34 library → FeeMath.fee(uint256,uint256) src/Vault.sol:9-11
├─ L35 internal → Vault._mint(address,uint256) src/Vault.sol:69-72 uses: require(bool,string)
│ └─ L71 internal → Token._mint(address,uint256) (via super) src/Base.sol:32-35
└─ L36 external-dep (not followed) → SafeMath2.add2(uint256,uint256) [lib/dep/src/SafeMath2.sol]
```
### 4. Following `IStrategy` into `AaveStrategy`
**What a developer sees:** the *Interface Bindings* panel on the overview page, with `IStrategy` set to
`AaveStrategy`. The same choice can be made from the picker on the interface card, and it re-renders every flow.

After binding, the `Vault.withdraw` graph replaces the `IStrategy.withdraw` stub with `AaveStrategy.withdraw`'s real body,
labeled `bound: IStrategy → AaveStrategy`:

**What the agent sees:** first, `list_unresolved(repo_path)` reports what couldn't be followed and suggests candidates:
```
Unresolved call sites: 1
## low_level_call (1)
src/Vault.sol:43 <address>.call(...)
reached from: Vault.withdraw(uint256)
## Interface calls with no known body (bind with set_interface_bindings)
IStrategy: reached from 1 entry point; candidates (inherit it or match all its methods): AaveStrategy, Vault
```
Then `set_interface_bindings(repo_path, {"IStrategy": "AaveStrategy"})` rebuilds every flow without recompiling:
```
Active bindings: {'IStrategy': 'AaveStrategy'}
Across all flows (before → after):
interface calls with no body: 1 → 0
calls followed via a binding: 0 → 1
other unresolved nodes: 1 → 1
```
After that, `get_flow(repo_path, "Vault.withdraw")` follows the call into the implementation. The node says the
binding was chosen, not proven:
```
Vault.withdraw(uint256) [external, mutating] src/Vault.sol:40-45 uses: require(bool,string)
├─ L40 modifier ReentrancyGuard.nonReentrant() src/Base.sol:20-25 uses: require(bool,string)
├─ L41 internal → Token._burn(address,uint256) src/Base.sol:37-41 uses: require(bool,string)
├─ L42 external → AaveStrategy.withdraw(uint256) (BOUND IStrategy→AaveStrategy: asserted, not proven) src/AaveStrategy.sol:9-12
└─ L43 UNRESOLVED [low_level_call] <address>.call(...) @ src/Vault.sol:43 (low_level/call)
```
### 5. Questions an agent asks directly
A developer answers these by clicking around the graph and reading code. An agent gets a tool for each.
**Who can reach this function?** `find_paths_to(repo_path, "_mint")`
```
3 entry points reach '_mint' (definitions: Token._mint(address,uint256), Vault._mint(address,uint256)). Shortest path each; [Ln] = call-site line in the caller.
• Vault.adminMint(address,uint256) mods=[onlyOwner] (2 matching calls in its tree)
[L48] Vault._mint(address,uint256)
• Vault.deposit(uint256) mods=[nonReentrant] (2 matching calls in its tree)
[L35] Vault._mint(address,uint256)
• Vault.mintTwice(address,address) mods=[onlyOwner] (4 matching calls in its tree)
[L53] Vault._mint(address,uint256)
```
**Show me this function.** `get_function_source(repo_path, "Vault._mint")`
```
function Vault._mint(address,uint256)
location: src/Vault.sol:69-72
attributes: internal, mutating
params: address to, uint256 amount
69 | function _mint(address to, uint256 amount) internal override {
70 | require(to != address(0), "zero");
71 | super._mint(to, amount);
72 | }
outgoing calls (static, before virtual dispatch):
L71 internal → Token._mint(address,uint256)
```
**What is this type?** `get_type_definition(repo_path, "Position")`
```
struct Position (src/Vault.sol:14-17)
14 | struct Position {
15 | uint256 shares;
16 | Kind kind;
17 | }
references: enum Kind
```
**Mistakes come back as instructions**, not stack traces:
```
No entry point 'Vault.withdrw'. Use list_entry_points to see valid names.
```
### How the UI maps to the tools
| In the SolFlow UI | Tool the agent calls |
|---|---|
| Overview page: contracts, writes and reads, badges | `list_entry_points` |
| Flow graph with code cards | `get_flow` (`include_source=true` for code) |
| Red `unr` badges, unbound interface cards | `list_unresolved` |
| Interface Bindings panel and **Save to solflow.toml** | `set_interface_bindings` (`save=true`) |
| CLI flags `--exclude-path`, `--stub-path`, `--inline-library` | `analyze_repo` scope arguments |
| Clicking through cards to read code | `get_function_source`, `get_type_definition` |
| Searching flows by hand for a function | `find_paths_to` |
### Why text and not images or JSON?
- **Images:** reading code from screenshots is slow and lossy for a model, and an image can't be quoted.
- **JSON:** carries the same information at roughly 1.5–1.9× the tokens. This was measured on real flows from a
35k-line protocol: a 177-node flow is ~5.5k tokens as a text tree and ~10.3k as JSON. Each line of the text tree
stands on its own and its depth is visible from the indentation. In deep JSON the model has to track brackets
across thousands of tokens to know which list of calls it's in.
---
## Tools
| Tool | Purpose |
|---|---|
| `analyze_repo` | Compile or recompile (`force=true`) and set scope (excludes, stubs, inlined libs, bindings). Returns a summary. Optional: other tools compile automatically. |
| `list_entry_points` | All external entry points, grouped by contract and split into mutating and read-only, with modifiers, depth, node count and unresolved count. |
| `get_flow` | The call tree for one entry point as an indented text tree. `include_source=true` inlines line-numbered code. |
| `get_function_source` | Source and metadata of any function or modifier, plus its outgoing calls. |
| `find_paths_to` | Reverse reachability: which entry points reach `X`, and by which chain? |
| `list_unresolved` | Calls that can't be followed statically (interfaces, `.call`, Yul) and the entry points that hit them, plus candidate implementations to bind. |
| `set_interface_bindings` | Bind an interface to a concrete contract and rebuild instantly. Optionally save to `solflow.toml`. |
| `get_type_definition` | Struct, enum or user-defined value type definitions. |
Compilation runs once per repo and is cached: a few seconds for a small repo, and about a minute for a 35k-line `via_ir` protocol. All later calls are instant.
## Install
### Requirements
- [uv](https://docs.astral.sh/uv/). It installs Python 3.12, SolFlow, Slither and the MCP SDK.
- A `solc` matching the target, set up with [solc-select](https://github.com/crytic/solc-select): `solc-select install 0.8.x && solc-select use 0.8.x`.
- The target project must build on its own first. Run `forge install` or `npm install` before analyzing.
### Claude Code
```bash
git clone https://github.com/fuzious/solflow-mcp.git
cd solflow-mcp && uv sync
claude mcp add solflow -- uv run --directory "$PWD" solflow-mcp
```
To make it available in every project, add `--scope user`.
### Other MCP clients (Claude Desktop, Cursor and so on)
```json
{
"mcpServers": {
"solflow": {
"command": "uv",
"args": ["run", "--directory", "/ABS/PATH/TO/solflow-mcp", "solflow-mcp"]
}
}
}
```
If the client can't find `solc`, add `"env": {"PATH": "<your PATH including solc-select's bin>"}`.
## Agent skill
[`skills/solflow-audit/SKILL.md`](skills/solflow-audit/SKILL.md) teaches an agent how to use these tools:
- the audit workflow and the order of calls;
- how to read every marker (`⚠`, `UNRESOLVED`, `BOUND`, `external-dep`, `NO IR`);
- scope controls;
- playbooks for access control, reentrancy and external-call ordering, and external trust boundaries;
- how to report what the analysis can't see.
Install it for Claude Code:
```bash
mkdir -p ~/.claude/skills && cp -R skills/solflow-audit ~/.claude/skills/
```
A typical agent session:
1. `list_entry_points(repo_path)`: map the attack surface.
2. `get_flow(repo_path, "Vault.withdraw", include_source=true)`: read one flow end to end.
3. `find_paths_to(repo_path, "_mint")`: find who can reach a sensitive internal function.
4. `list_unresolved(repo_path)`, then `set_interface_bindings(...)`: follow calls through interfaces.
5. After editing `.sol` files, run `analyze_repo(repo_path, force=true)`.
## Notes
- **Default scope:** excludes `**/*.t.sol`, `**/test/**`, `**/tests/**`, `**/mocks/**`, `*Mock*` contracts and dependency contracts (`lib/**`, `node_modules/**`). Project contracts still get the entry points they inherit from dependencies. Pass `use_default_excludes=false` to include them all.
- **Dependencies:** code under `lib/` and `node_modules/` is shown as `external-dep` and not followed, unless you pass `inline_libraries`. The same applies to dependency folders outside the project root, such as a monorepo package with `libs = ["../../lib"]`.
- **Monorepos:** point `repo_path` at the package that has the `foundry.toml`. When its libs or remappings reach outside the package (`../../lib`), solc sees some files under two names, and Slither then silently drops whole contracts. The server avoids this by passing absolute paths to forge through `FOUNDRY_LIBS` / `FOUNDRY_REMAPPINGS` for its own build. Your `foundry.toml` is never modified.
- **No silent gaps:** if Slither still fails on part of a build, `analyze_repo` prints `⚠ ANALYSIS GAPS`, `list_entry_points` names the missing contracts, and affected tree nodes are marked `NO IR`. The agent is never shown an incomplete tree as if it were complete.
- **Not a vulnerability scanner:** SolFlow shows structure. Finding bugs is the agent's job.
- **Local only:** analysis makes no network calls.
## Development
```bash
uv sync
uv run pytest # compiles the fixtures with solc and checks every tool
```
The tests compile [`tests/fixture`](tests/fixture), a single-package Foundry project, and
[`tests/fixture_monorepo`](tests/fixture_monorepo), a package whose libraries live outside its root, and check every tool's output against the source.
## License and credits
AGPL-3.0-only (see [LICENSE](LICENSE)), because this package imports SolFlow, which is AGPL-3.0-only.
All of the analysis is [SolFlow](https://github.com/norah1499/solidity-flow-navigator)'s work. This project is a text interface to it for agents.
TDQS
Scored across 8 tools
Each tool has a clearly distinct role in the Solidity call-flow analysis workflow: setup, entry-point listing, single-flow inspection, function source retrieval, reverse reachability, unresolved-call diagnostics, and interface binding. Overlap between get_flow and get_function_source is minimal because one targets entry-point call trees while the other retrieves arbitrary function source and metadata.
All tool names use consistent snake_case and begin with an action verb. The convention is predictable across setup, retrieval, listing, search, and configuration tools.
Eight tools is a well-scoped set for a Solidity static-analysis server. Each tool covers a distinct stage of the analysis workflow, and none appears to be filler.
The surface covers compilation, entry-point discovery, flow inspection, source retrieval, reverse paths, unresolved calls, and interface binding, which supports the core analysis workflow well. Minor gaps remain, such as no direct enumeration of internal functions or contracts beyond external entry points, which agents may need to infer through flows or known names.