Skip to main content
Glama
tarhou
by tarhou
README.md
# Chokepoint Finder MCP Server

The engine behind the Chokepoint Finder method: a FastMCP server with ten tools
that turn thousands of findings into **the smallest set of remediation actions that
eliminates the largest share of risk**, with fail-closed pre-flight safety gates,
typed execution plans, and verification that refuses to report clean when it could
not actually look.

Part of the Chokepoint Finder family:
[chokepoint-finder](https://github.com/tarhou/chokepoint-finder) (the agent) ·
[chokepoint-finder-skill](https://github.com/tarhou/chokepoint-finder-skill)
(the portable method) ·
[chokepoint-finder-playbook](https://github.com/tarhou/chokepoint-finder-playbook)
(the cross-vendor chain).

## Quickstart (zero credentials)

```bash
uv sync
uv run python -m chokepoint_finder.demo     # the full loop in the terminal
uv run python -m chokepoint_finder.report   # a board-ready HTML report
uv sync --extra dev && uv run pytest        # 167 tests
```

The demo estate is deterministic (seeded): every run shows the same collapse —
3,734 findings across 783 assets reduced to **7 actions covering 74% of weighted
finding risk (1,700 findings) and severing all 22 known attack paths** — plus a
pre-flight refusal and a wave-by-wave simulated delta. Synthetic data,
reproducible numbers, labelled SIMULATED in every report it produces. The demo
can prove the mechanics but can never authorize a real change-record close.

## The ten tools

`chokepoint_setup` (guided plumbing, never asks for a secret in conversation,
defaults to the offline source so it cannot hang on a captive portal),
`chokepoint_ingest` (demo | tenable | aws | all), `chokepoint_rank` (greedy weighted
max-coverage, marginal ranking), `chokepoint_supply_evidence` (relay EDR or
change-freeze evidence in from another connected MCP server; coverage that could
clear a gate requires explicit human confirmation),
`chokepoint_preflight` (EDR silence, change freezes, blast radius → PROCEED /
REQUIRES_STAGING / HOLD_PARTIAL / HOLD), `chokepoint_payload` (CAB-ready change
request), `chokepoint_plan` (typed execution workflow routed to your MCP fleet,
per-step approvals, canary-first waves, CTI out-of-band handoff; pass
`json_output=true` for the complete canonical v3 manifest),
`chokepoint_mark_executed` (requires a matching plan hash, explicit confirmation,
the exact wave, a targeted-rescan digest, and a recent one-time approval receipt),
`chokepoint_verify` (fail-closed, plan/wave-bound delta; pass `json_output=true` for
the structured proof receipt),
`chokepoint_demo` (the whole wave-aware loop at once).

Five tools update only this process's session state: ingest, rank, evidence relay,
execution accounting, and verification. None holds credentials for, or calls, an
external write API. Planning, payload generation, setup, and the complete demo remain
read-only at the MCP annotation boundary.

```json
{
  "mcpServers": {
    "chokepoint-finder": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/chokepoint-finder-mcp", "chokepoint-mcp"]
    }
  }
}
```

## The safety model

Three properties, each enforced in code and asserted by tests in
`tests/test_failclosed.py` rather than promised in prose.

**Evidence that is missing is not evidence of safety.** Every gate reads typed
`Evidence` carrying its source, collection time and completeness. Evidence that is
unconfigured, failed, partial, truncated or stale reads as `UNKNOWN`, and `UNKNOWN`
produces `HOLD`. Forgetting to wire up a feed can only ever make the agent *less*
willing to act. An empty result from a healthy feed is a different thing —
`ABSENT` — and does clear the gate.

**Evidence composes, the same way execution does.** Chokepoint Finder holds no EDR,
SIEM or ITSM credentials and never will. It sees that telemetry anyway: your agent
queries whatever server provides it — CrowdStrike, SentinelOne, Splunk, Sentinel,
ServiceNow, a CTI platform — and relays the answer through
`chokepoint_supply_evidence` with its provenance intact. The trust rules do not
soften for relayed data: stale, partial or truncated input still reads `UNKNOWN` and
still holds, a future collection timestamp is rejected outright, and every verdict
built on relayed evidence prints `[RELAYED by the operator's agent]` next to its
source. Evidence that travelled through an agent is one link weaker than a direct
pull, and a human reading a HOLD deserves to know which one they are looking at.
The failure mode that matters — an agent queries an EDR, gets an error, and relays
nothing — leaves the channel `UNKNOWN`, so giving up produces a refusal rather than
a silent proceed.
Relayed evidence also carries an exact `covered_asset_ids` set. Free-text scope is
audit context only; if any proposed target is absent from that set, the gate holds.
The confirmation rule is asymmetric: adding detections or freezes inside coverage the
gate already has needs no confirmation because it can only make the decision more
cautious. Establishing, refreshing, replacing, or extending coverage can turn a HOLD
into PROCEED. The first call therefore refuses with a SHA-256 preview digest. The
agent must present the exact evidence and digest to a human, then retry with
`confirm=true`, that digest, a recent confirmer identity/time, and a one-time receipt
ID; it may not self-attest. Changing the payload changes the digest, and replaying the
receipt is refused.

**Honest relay limitation.** The same agent is still the transport. A human who
confirms a relay is attesting to the evidence the agent handed them, not to a direct
read of the EDR, SIEM, or ITSM console. `confirm=true` does not make the relay an
authoritative pull. What it buys is a deliberate control boundary: fabrication that
could widen action now requires active human affirmation instead of happening silently
inside one tool call.

**Verification re-queries the authoritative source and fails closed.**
`chokepoint_verify` runs a fresh collection and grades it: `VERIFIED`, `PARTIAL`,
`NOT_FIXED`, `UNKNOWN` or `ERROR`. Auth failure, timeout, malformed payload,
truncation, partial collection and stale evidence all resolve to `UNKNOWN`/`ERROR`,
and only a non-simulated, final-wave `VERIFIED` result permits closing a change
record. Verification also requires the re-query to match the baseline tenant/account,
credential principal, query and region scope. An empty result set against a
non-empty baseline is treated as a broken query unless the approved action was expected
to retire the entire baseline and the collector independently proves complete, exact
asset coverage. This prevents an outage from masquerading as a fix without making a
genuine all-clear impossible. In demo mode the re-query is a simulation, says so in its
status line, and can never authorize closure.

**Every external mutation requires approval bound to the exact plan.** Steps carry
a structural `mutates_external_state` flag, and the `Step` constructor refuses to
build one that mutates without requiring approval. "External" is drawn deliberately
wide: opening a change ticket, notifying a SOC, and launching a scan all count, not
just touching infrastructure. Approval binds to `ExecutionPlan.plan_hash`; change
the source scope, complete finding IDs, complete targets, exact mutation args, ordered
waves, or held assets and the full SHA-256 hash changes, voiding the earlier approval.
Each wave must be recorded and verified before the next becomes eligible. The server
holds no write credentials and calls no write API — execution
happens through the operator's own MCP servers, as a separate human-invoked phase.
The human Markdown is intentionally bounded and omits bulk identifier arrays; the full
v3 JSON manifest carries exact identifiers, mutation arguments, and ordered steps. For
a three-wave plan, target mutation, targeted rescan, execution receipt, and proof are
physically interleaved for wave 1 before any wave-2 target is touched. Proof binds to
the approved plan hash, exact wave, authoritative scope, and rescan receipt.

Attack-path credit is also evidence-bound. A fix severs a path only when the source
explicitly maps that remediation target to the path; mere overlap between a target
asset and a path node is not counted as disruption.

## How this is tested

167 tests, of which the load-bearing ones are in `tests/test_failclosed.py`:
they are written to fail if a safety claim stops being true, which is the only
reason a safety claim is worth making.

Each control was verified by reverting it and confirming the test catches it —
a regression test that passes against the broken code proves nothing. That
exercise found real gaps twice: an injection test that only checked the
sanitiser rather than the call sites, and an AWS fixture whose STS branch was
never reached.

What the suite covers: every way a re-query can fail (auth, timeout, malformed
payload, truncation, staleness, wrong scope) resolving to UNKNOWN or ERROR and
never to clean; unconfigured, stale or under-scoped evidence holding a gate;
approval bound to a plan hash and refused when the plan changes; wave ordering
that cannot skip ahead; coverage arithmetic that cannot exceed 100%; finding
identity stable under reordering; and prompt-injection payloads driven through
the real MCP tools — not the sanitiser in isolation — asserting no rendered
line can become a model instruction.

The attack-path collector was additionally verified against a live Tenable One
tenant: 2,285 vectors, 24 distinct remediations, with partial coverage and
unbound vectors counted and surfaced rather than silently dropped.

## The ranking, and what is actually guaranteed

The library default (`ranking_mode="risk"`, with both early stops disabled) is greedy
on marginal objective gain under
a cardinality constraint. The objective — covered finding risk plus a bonus per
newly severed attack path — is monotone and submodular, so the classical
(1 − 1/e) ≈ 63% approximation bound applies (Nemhauser, Wolsey & Fisher 1978), and
no polynomial algorithm does better unless P = NP (Feige 1998).

The MCP shortlist intentionally uses an early target-share stop and prints that the
classical guarantee does not apply to that run. More generally, the early stops
(`target_share`, `min_marginal_share`) trade that k-cardinality bound for a shorter
list, and the bound is over the objective, not over any single reported percentage.

`ranking_mode="effort_weighted"` divides marginal gain by `effort^exponent`. It is
useful and it carries **no approximation guarantee** — ratio-greedy under a
cardinality constraint can be arbitrarily bad. It is opt-in and named honestly
rather than sold under a theorem it does not satisfy.

**Coverage is reported as two numbers, never blended.** Finding-risk coverage has
total finding risk as its denominator; attack-path disruption has the path count.
Mixing them is how a tool reports more than 100% of something, so the code keeps
the path bonus out of every human-facing percentage.

## Live sources

Tenable Vulnerability Management via the export APIs
(`uv pip install ".[tenable]"`, `TENABLE_ACCESS_KEY` / `TENABLE_SECRET_KEY`), and
AWS via Security Hub + IAM + EC2 security groups (`uv pip install ".[aws]"`,
standard credentials, read-only, `SecurityAudit` suffices). Copy `.env.example` to
`.env`, or use the environment. Run `chokepoint_setup` first: it reports
READY / TO DO / FAILED per source with the exact next step.

Finding identity is derived from source-natural keys — `(asset uuid, plugin id,
port/protocol)` for Tenable, and the Security Hub finding ID plus every sorted resource
and vulnerability identity for AWS — never from
enumeration order, so a re-query that returns rows in a different order still
compares correctly against the baseline.

## Limitations

These are the things this tool does **not** do. They are listed because a security
tool that hides its edges is worse than one that has them.

- **The demo estate is synthetic.** Its numbers are illustrative, not benchmarks.
- **No bundled EDR/SIEM/ITSM collector, by design.** Evidence comes from the MCP
  servers you already have connected, relayed in through `chokepoint_supply_evidence`
  (see above). This tool holds no credentials for CrowdStrike, Splunk or ServiceNow
  and is not trying to. The consequence to be aware of: if nobody relays a channel,
  it stays `UNKNOWN` and the gates **hold**. Refusing is the safe direction, but an
  unattended run against live data will refuse a lot until the evidence path is
  wired up.
- **Attack paths need Tenable One APA.** The Tenable collector reads APA *vectors*
  -- what Tenable calls attack paths -- paginating to the tenant's reported total,
  and binds each to the plugin APA says severs it. Deliberately not the findings
  endpoint: a finding is a technique that can occur in many paths (one observed
  finding sat on 39 vectors), so counting findings as paths yields a denominator
  that is neither the path count nor a stable quantity. A path is credited only
  when APA names the remediation that breaks it; node overlap earns nothing.
  Without an APA licence -- and on the AWS collector, which has no equivalent
  surface -- zero paths are collected and the reason is recorded, rather than
  reported as "this estate has no attack paths". Unbound vectors, vectors
  referencing assets outside the ingest, and any short read are counted and
  surfaced, so partial coverage is never presented as complete.
- **Collectors are read-only and not load-tested** against very large tenants;
  exports are capped, and hitting the cap marks the collection truncated, which
  blocks verification rather than degrading it silently.
- **Ranking quality tracks metadata quality.** Findings with no fix-sharing key
  cannot converge into a chokepoint. Garbage in, defensible out — every ranking
  cites the evidence behind it, so a wrong answer is visible rather than opaque.
- **HTTP is loopback-only development transport.** It has no authentication or
  session isolation and uses one process-global state, so the CLI refuses non-loopback
  binds. Use stdio for real operator/tenant boundaries and a separate process per
  session.
- **Finding text is untrusted input.** Titles and remediation strings are normalized
  into bounded labels or escaped `<untrusted-scanner-data>` JSON, never interpreted as
  instructions; prompt-injection tests exercise the model-facing rendering boundary.

Authors: Zane K ([@zkilling](https://github.com/zkilling)), Tarek H ([@tarhou](https://github.com/tarhou)), AJ ([@Ethosmos](https://github.com/ethosmos)). MIT license.

TDQS

A4/5.0

Scored across 10 tools

Disambiguation4/5

Most tools have clearly distinct roles in the workflow, but some pairs like chokepoint_payload and chokepoint_plan (both produce outputs for a chokepoint) could be confused. Similarly, chokepoint_supply_evidence and chokepoint_preflight are closely related input/evaluation steps.

Naming Consistency4/5

All tools share the consistent 'chokepoint_' prefix, but the second part mixes verbs (ingest, setup, rank, verify) with nouns (payload, plan) and verb phrases (mark_executed, supply_evidence). This is readable but not perfectly uniform.

Tool Count5/5

10 tools is well within the ideal 3-15 range. Each tool maps to a distinct phase in the chokepoint-finding and remediation workflow, and none feel redundant or unnecessary.

Completeness5/5

The tool set covers the full lifecycle from setup and ingest to ranking, evidence gating, plan/payload generation, execution marking, and verification. The design intentionally leaves out direct execution, which is documented and consistent with the server's zero-trust posture.

Maintenance

ActivitySlowing
ResponsivenessNo issues