pathhound-mcp
by iabdullah215
README.md
# pathhound-mcp
An **MCP (Model Context Protocol) server** that lets an LLM reason over an Active
Directory attack graph **already collected by BloodHound** — shortest paths to
Tier 0, blast radius from a principal, the highest-leverage choke points to
remediate, and defender-facing remediation text — with every action scoped to an
authorized engagement and logged.
> **Authorized penetration-testing use only.** This server never touches a live
> domain, never collects data, and never runs attacks. Collection is done
> separately (SharpHound / `bloodhound-python`); this tool only *reads and
> reasons over* the resulting graph in Neo4j. See [SECURITY.md](SECURITY.md) for
> the full intended-use boundary.
## What it does
- **Turns a collected graph into explainable answers.** Ask "what are the
shortest paths to Domain Admins," "if I own this account what becomes
reachable," or "which edges should we fix first" and get structured results.
- **Read-only and scoped by design.** It refuses to start without an engagement
scope, refuses to query domains outside that scope, rejects write/destructive
Cypher, and appends every call to an audit log.
- **Defender-facing output.** `explain_edge` and `remediation_for_path` produce
report-ready remediation, not exploit commands.
### Tools
| Tool | What it returns |
| --- | --- |
| `list_domains()` | Domains/forests in the graph, with node counts and an in-scope flag. |
| `run_cypher(query, params)` | Rows from a **read-only** Cypher query. Write clauses are rejected unless `allow_writes` is set; destructive/external ops are always refused. Results are row-capped with a truncation flag. |
| `shortest_paths_to_tier0(start, max_paths)` | Shortest attack paths to Tier 0 / high-value targets — from `start` (an in-scope principal) or from every `owned` principal. Each path is an ordered list of `start —[edge]→ end` hops. |
| `reachable_from(principal, max_hops)` | Blast radius from a principal, summarized by node type, highlighting any Tier 0 reachability. |
| `find_choke_points(top_n)` | The edges appearing in the most distinct shortest paths to Tier 0, each with the number of paths removing it would break. |
| `explain_edge(edge_type)` | Plain-language reason a BloodHound edge (e.g. `GenericAll`, `AddKeyCredentialLink`, `DCSync`) is abusable, the technique name, a defender remediation, and reference pointers. |
| `remediation_for_path(path_id_or_cypher)` | For a path (given as a read-only Cypher query returning a path `p`), the specific ACE / delegation / membership change that breaks each hop, phrased for a report. |
| `engagement_summary()` | Headline counts for reporting: principals, owned, high-value, distinct paths to Tier 0, and the top choke points. |
### Resources
| Resource | Contents |
| --- | --- |
| `pathhound://engagement-scope` | The active scope (engagement id, authorization reference, allowed domains, write flag) and the Neo4j target with the password masked. |
| `pathhound://cypher-library` | A curated, versioned set of named read-only Cypher queries (Kerberoastable, unconstrained delegation, AS-REP roastable, paths to Domain Admins, ACL abuse from a principal, …), each validated read-only on load. |
### Optional write tools (off by default)
Two tools — `mark_owned(principal, confirm)` and `set_high_value(principal,
confirm)` — set BloodHound's owned / Tier 0 markers (and nothing else) so the
path tools treat those nodes accordingly. They only exist when `allow_writes:
true` is set in `scope.yaml`; each call also requires an explicit `confirm=true`,
an in-scope principal, and is audited. They never delete or drop. Leave
`allow_writes: false` (the default) for a strictly read-only deployment.
## Requirements
- Python 3.11+
- A **BloodHound CE** Neo4j instance reachable over Bolt (the graph is collected
and ingested separately — see the quickstart below)
## Install
```bash
# With uv (preferred):
uv venv && uv pip install -e ".[dev]"
# Or with pip:
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt && pip install -e .
```
## Configure
Two pieces of configuration: the **engagement scope** (a file) and the **Neo4j
connection** (environment variables — credentials are never hardcoded).
**1. Engagement scope** — copy the example and edit it:
```bash
cp scope.example.yaml scope.yaml
```
```yaml
# scope.yaml
engagement_id: "ENG-0001" # names the audit log file
authorization_ref: "SoW-2026-ACME-001 / JIRA-1234"
allowed_domains: # only these may be queried
- "CORP.LOCAL"
- "CHILD.CORP.LOCAL"
allow_writes: false # keep false unless you need the write-flag tools
max_rows: 500 # run_cypher row cap
```
The server **refuses to start** if `scope.yaml` is missing or `allowed_domains`
is empty — this is intentional; there are no unscoped queries. Domain names are
matched case-insensitively against BloodHound's uppercase FQDNs.
**2. Neo4j connection** — via environment variables (or a `.env`; see
`.env.example`):
```bash
export NEO4J_URI="bolt://127.0.0.1:7687"
export NEO4J_USER="neo4j"
export NEO4J_PASSWORD="…"
export NEO4J_DATABASE="neo4j" # optional, defaults to neo4j
# export PATHHOUND_SCOPE="/abs/path/scope.yaml" # optional; defaults to ./scope.yaml
```
## Run
As a standalone stdio MCP server:
```bash
pathhound-mcp # or: python -m pathhound_mcp.server
```
### Register with an MCP client
Point your client at the console script (use the absolute path from your venv,
e.g. `/path/to/pathhound-mcp/.venv/bin/pathhound-mcp`) and pass the connection +
scope through its environment. Example (Claude Desktop `claude_desktop_config.json`):
```json
{
"mcpServers": {
"pathhound": {
"command": "/path/to/pathhound-mcp/.venv/bin/pathhound-mcp",
"env": {
"NEO4J_URI": "bolt://127.0.0.1:7687",
"NEO4J_USER": "neo4j",
"NEO4J_PASSWORD": "…",
"PATHHOUND_SCOPE": "/path/to/pathhound-mcp/scope.yaml"
}
}
}
}
```
### Example questions to ask the LLM
Once connected, an operator can ask things like:
- *"List the domains in the graph and how big each is."* → `list_domains`
- *"Which accounts are Kerberoastable?"* → `run_cypher` with the `kerberoastable_users` library query
- *"Show the shortest attack paths to Tier 0."* → `shortest_paths_to_tier0`
- *"If I compromise `alice@corp.local`, what becomes reachable?"* → `reachable_from`
- *"What are the top 10 choke points we should fix first?"* → `find_choke_points`
- *"Why is `AddKeyCredentialLink` dangerous, and how do we fix it?"* → `explain_edge`
- *"Give me remediation steps for this attack path."* → `remediation_for_path`
- *"Summarize the engagement for the report."* → `engagement_summary`
## Quickstart: collect into BloodHound, then point this at Neo4j
This server only reads an **already-collected** graph; collection happens
separately and outside this tool.
1. **Collect** the domain with SharpHound or `bloodhound-python` during your
authorized engagement. You get a set of JSON files (or a zip) — from a lab
like [GOAD](https://github.com/Orange-Cyberdefense/GOAD) (GOAD-Light ~16 GB,
full GOAD ~32 GB) or a real engagement.
2. **Stand up BloodHound CE and ingest the data.** The official compose stack
runs Neo4j (Bolt on `127.0.0.1:7687`), Postgres, and the BloodHound UI on
`:8080`:
```bash
docker compose -f /path/to/docker-compose.yml up -d
# open http://localhost:8080, log in, and upload the collection zip
# (Administration → File Ingest). Wait for ingest + post-processing.
```
If the Neo4j volume is already ingested, you can start just the graph:
`docker compose -f /path/to/docker-compose.yml up -d graph-db`.
3. **Point pathhound-mcp at that Neo4j** (see [Configure](#configure)) and run
it. Your `allowed_domains` in `scope.yaml` should match the domains in the
collection (e.g. `CORP.LOCAL`).
### BloodHound schema compatibility
Newer BloodHound CE tags **Tier 0 with the `Tag_Tier_Zero` node label** and
**owned principals with `Tag_Owned`**, while older versions used the `highvalue`
/ `owned` properties (and CE also sets `system_tags: admin_tier_0`). pathhound
recognizes all of these, so Tier 0 / owned reasoning works across versions.
## Tests
```bash
pytest # unit tests — guardrails, scope, audit, Cypher guard, tool logic
```
The suite is fully **database-free**: the read-only guardrail, scope enforcement,
audit logging, row-capping, and path/reporting logic are all exercised against
stubs, so `pytest` runs in a fraction of a second with no Neo4j. (The tools have
also been validated end-to-end against a real BloodHound CE graph.)
## Project layout
```
src/pathhound_mcp/
├── server.py # FastMCP app: tool/resource registration + startup guard
├── config.py # loads/validates scope.yaml (pydantic); Neo4j from env
├── guardrails.py # read-only Cypher guard + scope enforcement
├── audit.py # append-only JSONL audit logger (@audited decorator)
├── graph.py # Neo4j connection + read helpers (the only DB access)
├── schema.py # attack-edge set + Tier 0 / owned predicates
├── models.py # pydantic result models
├── cypher_library.json / edge_library.json # curated read-only queries + edge KB
└── tools/ # inventory, query, paths, reporting, edges
```
`audit/`, `scope.yaml`, and `.env` are gitignored — treat the audit log and the
collected graph as engagement-sensitive.
## Safety model
See [SECURITY.md](SECURITY.md) for the intended-use boundary and the guarantees:
scope-at-startup, read-only by default, scope enforcement, append-only audit log,
and no live-domain capability.
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues