Skip to main content
Glama
io.github.sabastiaz/tenable-mcp

io.github.sabastiaz/tenable-mcp

by Sabastiaz
README.md
<!-- mcp-name: io.github.sabastiaz/tenable-mcp -->

# Tenable MCP โ€” four products, read-only, and an Active Directory attack graph

Ask your AI assistant about Tenable, and turn what Tenable already knows about Active
Directory into a **BloodHound attack graph** โ€” without running a collector.

49 tools across Tenable Vulnerability Management, Security Center, Identity Exposure and
Cloud Security. Read-only, enforced at the HTTP layer, so you can point it at production.

> ๐Ÿ‡น๐Ÿ‡ญ **เธ„เธนเนˆเธกเธทเธญเธ เธฒเธฉเธฒเน„เธ—เธข** โ€” [docs/getting-started.th.md](docs/getting-started.th.md)

> Independent community project. Not affiliated with, endorsed by, or supported by Tenable, Inc.

---

## Try it in 30 seconds โ€” no API key

Getting Tenable API keys out of a real organisation takes days. You should not have to do
that to find out whether this is worth installing.

No clone, no checkout โ€” one command:

```bash
TENABLE_MCP_DEMO=true uvx --from git+https://github.com/Sabastiaz/tenable-honest-mcp tenable-mcp --selftest
```

All four backends answer from synthetic data, and no network call is ever made. Point a
client at it the same way:

```json
{
  "mcpServers": {
    "tenable-demo": {
      "command": "uvx",
      "args": [
        "--from", "git+https://github.com/Sabastiaz/tenable-honest-mcp",
        "tenable-mcp"
      ],
      "env": { "TENABLE_MCP_DEMO": "true" }
    }
  }
}
```

<details>
<summary>Prefer a local checkout?</summary>

```bash
git clone https://github.com/Sabastiaz/tenable-honest-mcp.git && cd tenable-honest-mcp && uv sync
```

```bash
TENABLE_MCP_DEMO=true uv run tenable-mcp --selftest
```

Then use `"command": "uv"` with
`"args": ["run", "--directory", "/absolute/path/to/tenable-honest-mcp", "tenable-mcp"]`.

</details>

Then ask it things:

- *"export our AD from Identity Exposure to BloodHound"*
- *"how many critical vulnerabilities do we have?"* โ† watch it refuse to give you a bare number
- *"check Tenable health"*

The demo tenant is deliberately built so the traps fire: the workbench returns exactly
5,000 rows, the tenant has no Lumin licence, and Identity Exposure has two profiles.
**The tools run their real code** โ€” only the network calls are replaced โ€” so what you see
is what the server does against a live tenant.

---

## The BloodHound bridge

A lot of teams have BloodHound sitting installed from a pentest, with no current data in
it. Meanwhile Tenable Identity Exposure is already watching the same Active Directory,
continuously.

`tie_export_bloodhound` reads the directory from Identity Exposure and writes standard
SharpHound-format ingest files:

```
users.json  groups.json  computers.json  domains.json  ous.json  gpos.json  containers.json
<domain>_bloodhound.zip     โ† all of the above, ready to upload
```

Upload the **zip** through BloodHound's **Administration โ†’ File Ingest**, and you have a
graph. One upload instead of seven. The entries sit at the archive root, because
BloodHound reads the entry names to decide what each file is โ€” nested inside a folder it
accepts the upload and ingests nothing.

**Why export rather than build a graph here.** Identity Exposure has no graph API โ€” none of
its 88 endpoints do traversal. Rather than reimplement a graph engine badly, this hands the
data to the tool that already has one, with Cypher, ADCS analysis and a real UI.

### What the graph will and will not contain

Being precise about this matters more than the feature itself. **A BloodHound graph missing
edges can show "no path to Domain Admin" when paths exist**, and reporting that as "we're
fine" is worse than having no graph at all.

| Included | Not included |
|---|---|
| Users, groups, computers, the domain | Sessions โ€” needs a live host collector |
| Group membership โ†’ `MemberOf` edges | Local admin rights and user-rights assignments |
| **ACL edges** โ€” GenericAll, WriteDacl, WriteOwner, Owns, ForceChangePassword, AddMember, AddSelf, WriteSPN, AddKeyCredentialLink, AllExtendedRights, Enroll | LAPS / gMSA password reads โ€” that attribute's GUID is schema-specific, not fixed |
| **Replication rights** (`GetChanges` + `GetChangesAll`), which BloodHound post-processes into a **DCSync** edge | GPO links and OU containment |
| **RBCD** via `msDS-AllowedToActOnBehalfOfOtherIdentity` | Cross-domain trusts, unless every domain is exported together |
| Account properties, correct well-known SID handling | |

So it answers *"who is effectively Domain Admin"* and *"who can take over this account"*,
but not *"where is the Domain Admin logged in right now"*. Every export response repeats
these gaps, and the tool description instructs the assistant to repeat them to you.

`nTSecurityDescriptor` is stored as plain SDDL text, so it is parsed directly โ€” no base64
step. The parser never raises: a directory holds tens of thousands of descriptors and one
malformed string must not abort the export, so an unreadable descriptor is counted and
reported instead. Read-only ACEs, deny ACEs, and grants to SYSTEM and SELF are filtered
out, because every object grants those and the noise would bury the real paths. The one
deliberate exception is replication rights held by ENTERPRISE DOMAIN CONTROLLERS โ€” that is
exactly how DCSync works.

Identity Exposure's own wire format is the other half of the problem. Every attribute
value is JSON-encoded (a SID arrives as `'"S-1-5-โ€ฆ"'`, quotes included), `userAccountControl`
is a space-separated flag string rather than a bitmask, and TIE returns roughly 1.29 raw
rows per real object. Each of those, handled naively, empties the graph without an error.

Three BloodHound format rules matter for the same reason โ€” getting them wrong makes
BloodHound accept the upload and render an empty graph, silently:

- `computers.json` below schema **version 5 ingests nothing at all**, with no error.
- Well-known SIDs must be domain-prefixed (`CORP.LOCAL-S-1-5-32-544`) or every edge to
  Administrators, Everyone and Authenticated Users dangles.
- Node IDs are case-sensitive; a lowercase SID silently points at a phantom node.

Objects without a usable SID are **skipped and counted**, never given a synthetic ID โ€” a
made-up identifier creates edges to nodes that do not exist.

---

## The rest of it

| Backend | Tools | What you can ask |
|---|---|---|
| **Tenable VM** (`tvm_*`) | 22 | "what should we patch first", "did we fix X", full exports |
| **Security Center** (`tsc_*`) | 11 | the same, on-prem, plus before/after comparison |
| **Identity Exposure** (`tie_*`) | 11 | AD exposure, indicators of attack, BloodHound export |
| **Cloud Security** (`tcs_*`) | 5 | misconfigurations and cloud workload vulnerabilities |
| **PagerDuty** (`ticket_*`) | 4 | raise an incident from a finding โ€” off by default |

Configure only the products you have. Tools for an unconfigured backend are never
registered, so an assistant cannot ask for data the server has no credentials to fetch.

## Don't trust a filtered number you haven't proven

Tenable's workbench accepts filters it does not apply, answers `200`, and returns the whole
corpus. Nothing says a filter was dropped, so a filtered query that was not filtered reads
exactly like a real result. Three tools exist because of that:

* **`tvm_preflight`** runs small arithmetic proofs on *your* tenant: two mutually exclusive
  queries must add back to the unfiltered total; a widening date window must never shrink; a
  multi-value filter must equal the sum of its values; a boolean parameter must change the
  result between true and false. Each filter gets a verdict (`applied`, `ignored`,
  `unexpected`, `undetermined`, `unavailable`) with the counts and the literal query
  parameters behind it. Run it once per tenant, and again when a number looks too good.
* **`tvm_export_mttr`** computes mean time to remediate from a finished `state=['FIXED']`
  export โ€” the only route to it, since `last_fixed` is absent from the inventory API โ€” and
  returns the arithmetic with every choice that moves it. A fix date is the scan that noticed
  the fix, so when most fixes land on a few dates the durations describe the scan calendar.
  That is judged against the period's *own* average rate, not a fixed per-day cutoff, which
  would brand any team fixing seven findings a day as "closing in batches".
* **`tvm_compare_scans`** returns the exact `queries_sent` for each count so it can be
  replayed by hand.

Findings that came out of building this, each now a regression test:

| Silent failure | What it did | Now |
|---|---|---|
| `exploitable=true` / `false` | accepted by the API and ignored โ€” identical rows | never sent; applied via the tenant's own exploit-availability filter, or refused |
| `severity=["critical","high"]` | became two AND-ed filters and matched nothing | one filter with a comma-joined value |
| ISO-8601 `first_found` in an export | the summary skipped every non-integer timestamp and reported `null` | both forms are parsed |
| the unfiltered workbench view | hides `info` severity and `Fixed` findings (525 and 16 plugins live), and `tvm_vuln_summary` printed `info: 0` | info reported as `null` with a note; `tvm_compare_scans` names its severity scope |
| a per-day "batch" cutoff of 5 | flagged steady high-volume remediation as batching | spike days are relative to the period's own rate |

## Point it at production

Read-only is enforced in three independent layers, not by convention:

1. **No write tool is defined**, and every Tenable tool carries `read_only_hint=True`.
2. **Every request passes a guard.** The pyTenable clients subclass `APISession._req`, the
   single funnel all HTTP goes through, and non-GET requests are rejected unless they match
   an explicit allowlist โ€” which contains only the export-job POSTs. For Cloud Security,
   where reads and writes share one GraphQL endpoint, the guard inspects the *operation*
   rather than the verb.
3. **Start-up refuses to boot** a server whose tool set contains anything unannotated, and
   equally refuses if the allowlist names a tool that no longer exists.

It cannot launch, stop or edit a scan; create or assign tags; accept or recast risk; change
ACR; delete assets; or touch users, groups, roles or credentials. In Identity Exposure it
also cannot call `POST /api/api-key` โ€” which *renews* the key and would lock the server out
of its own tenant.

**The exceptions, stated plainly:** `tvm_export_cancel` cancels an export job this server
created (freeing one of the tenant's 10 slots). And if you explicitly enable ticketing,
`ticket_send` / `ticket_resolve` write to PagerDuty โ€” never to Tenable.

To verify the claim on your own tenant, use the tools and then read your Tenable audit log
for the same window: you should see nothing but export create/cancel entries.

---

## Why not just wrap the API

Tenable's API is unusually good at returning a response that looks complete and is not. Most
of its limits produce **no error** โ€” just fewer rows, or a narrower window than you asked
for. Each of these is handled here:

| Trap | What actually happens | What this server does |
|---|---|---|
| Workbench endpoints cap at **5,000 rows** | No offset, no error, no flag | Detects it, sets `truncation_reason`, points at the export path |
| Export with **no time filter** | Silently returns only the last **30 days** | Defaults to 90 days *and says so in the response* |
| `since` | Returns open findings **plus fixed ones** | Stated in the parameter description; a prompt teaches the correct form |
| UI vs API vocabulary | "New" and "Active" both mean `OPEN` | Documented on the `state` enum |
| Null AES / ACR | Means **no Lumin licence**, not "no risk" | Detected at health check; a clear error replaces the confusing nulls |
| Identity Exposure filters | An unknown attribute returns **200 and an empty list** | Validates the expression first; refuses shapes that match nothing |
| Cloud Security | An unvalidated token returns **empty, not 403** | `tcs_health` tells "no access" apart from "no findings" |

Every list response also passes a central size budget and reports what it truncated and
why, so an assistant never quietly reports a partial answer as a total.

---

## Install and configure

```bash
cp .env.example .env
```

Fill in whichever products you have โ€” at least one:

```bash
# Tenable Vulnerability Management
TENABLE_VM_ACCESS_KEY=<64-char hex>
TENABLE_VM_SECRET_KEY=<64-char hex>
# FedRAMP tenants only โ€” commercial keys do not work against fedcloud, or vice versa:
# TENABLE_VM_REGION=fedramp

# Tenable Identity Exposure
TENABLE_IE_URL=https://yourtenant.tenable.ad
TENABLE_IE_API_KEY=<from Preferences โ†’ API key>

# Tenable Cloud Security โ€” regional, no default, and a bearer token (not VM keys)
TENABLE_CLOUD_URL=https://us.app.ermetic.com
TENABLE_CLOUD_TOKEN=<from the Cloud Security console>

# Tenable Security Center
TENABLE_SC_HOST=securitycenter.example.com
TENABLE_SC_ACCESS_KEY=<access key>
TENABLE_SC_SECRET_KEY=<secret key>
```

> **Give this server its own Tenable service account.** Rate limits are enforced per API
> key, so sharing one with a SIEM connector or a nightly export job makes them contend.

Verify before wiring it into a client:

```bash
uv run tenable-mcp --selftest
```

Then use the same client config as the demo above, minus `TENABLE_MCP_DEMO`. Absolute paths
are required โ€” the client runs the server from a different working directory.

---

## Exports

Workbench endpoints cap at 5,000 rows, so exports are the only way to get a complete data
set. A Tenable export takes minutes, which does not fit inside one tool call โ€” so
`tvm_export_start` returns a job handle immediately and `tvm_export_status` reports
progress.

Results spool to **gzipped NDJSON on disk** as chunks arrive, so memory stays flat
regardless of size. `tvm_export_fetch` reads them back as a constant-size summary, as paged
rows, or as a file path for offline analysis. The spool is deleted after 24 hours, matching
Tenable's own chunk expiry.

## Rate limiting

Tenable does not publish a requests-per-second figure โ€” the platform computes an allowance
per API key from current load โ€” so the limiter starts at 6 req/s, halves on any 429, holds,
then recovers. Requests are **serialised by default**, per Tenable's explicit guidance not
to multi-thread, with a separate semaphore of 3 for the endpoints Tenable caps at three
concurrent requests.

Since `mcp` 2.0 runs synchronous tool handlers on worker threads, every tool here is
`async` and offloads its call inside the limiter โ€” otherwise the semaphore would serialise
nothing.

---

## Development

```bash
uv run pytest
```

```bash
uv run ruff check && uv run mypy src/
```

The suite is mostly about invariants that matter: a parametrised matrix of mutating
endpoints across all four products that must all be blocked; that a write never reaches the
transport layer; that the 30-day export default cannot regress; that the response size
budget holds including its own metadata; that the BloodHound format rules are obeyed; and
that ticketing cannot send without an explicit confirmation.

### Known constraints

- **pyTenable is pinned to 26.6.1.** Its `workbenches` module emits a deprecation warning
  steering callers to exports; the underlying REST API is not deprecated, but a future
  major may remove the wrapper.
- **Cloud Security does not use pyTenable.** Its bundled GraphQL documents still select a
  field the server removed in 2024, and it validates client-side before sending โ€” so the
  call fails locally looking like an SDK bug. A small hand-written client is used instead.
- **Security Center's API carries no compatibility guarantee** from Tenable, unlike the
  versioned cloud API.
- **pyTenable's Identity Exposure wrappers are not used.** Against a live tenant running
  IE 3.121, eight of its ten wrapped calls fail: some are marshmallow schemas that reject
  fields the API now returns (`directories`, `checkers`, `attack-types`), and others name
  methods the SDK does not have (`ad_object.search`, `alerts.list`, `about.details`). Every
  IE tool therefore calls the REST endpoint directly, through the same read-only guard.
- **Identity Exposure does not expose deviance severity** on any endpoint reachable by API
  โ€” it is a per-profile console value. `tie_list_deviances` reports `severity: null` and
  says so rather than inventing a band.
- **pyTenable 26.6.1's workbench wrappers crash on live tenant data.**
  `workbenches.assets()`, `workbenches.vulns()` and their siblings unconditionally call
  `FiltersAPI.workbench_*_filters()` before building any query โ€” even with zero filters โ€”
  and pyTenable's own `_normalize()` does `item['control']['list'][0]` with no length
  check. A live tenant's `sources` asset filter legitimately has an empty `control.list`
  (no scan source configured yet), which crashed `tvm_search_assets` with a bare,
  untranslated `IndexError`. `tvm_search_assets`, `tvm_vuln_summary` and
  `tvm_search_vulns` now call the raw `workbenches/*` endpoints directly, replicating
  pyTenable's own query construction (`vm_workbench.py`). `tvm_list_filters` now fetches
  with `normalize=False` and normalises the result itself, defensively. `vuln_info`,
  `vuln_outputs` and `vuln_assets` (used by `tvm_vuln_detail` and `tvm_vuln_outputs`) share
  the same landmine and were fixed the same way โ€” same endpoints and response keys, read
  from pyTenable's own source rather than guessed. All of this is now confirmed against a
  live tenant with working vuln-filter access: `tvm_search_vulns`, `tvm_search_assets`,
  `tvm_vuln_summary`, `tvm_list_filters`, `tvm_vuln_detail` and `tvm_vuln_outputs` all
  return real data โ€” including plugin evidence text (`tvm_vuln_outputs` returned the exact
  missing registry values for a live CVE-2013-3900 mitigation finding, across three
  affected hosts) and full risk detail (VPR drivers, CVSS vectors, exploit status) via
  `tvm_vuln_detail`.
- **`tvm_compare_scans` never hardcodes Tenable's workbench state filter name** โ€” it is
  undocumented (Tenable's own API reference for `GET /workbenches/vulnerabilities` lists
  only `date_range` and the generic filter mechanism, then points at each tenant's own
  `GET /filters/workbenches/vulnerabilities` for names). Confirmed against a live tenant:
  the real filter is named neither `state` (the export API's field) nor
  `vulnerability_state` (a reasonable guess from the response schema) but
  **`tracking.state`** โ€” a third name this server never assumed, discovered correctly at
  call time from the tenant's own filter catalogue, with values `Fixed` /
  `New, Active, Resurfaced`. Hardcoding any of the two guessed names would have broken
  silently on this tenant.
- **`tvm_preflight`, `tvm_export_mttr`, and the `exploitable` and `severity` fixes have been
  run against a live tenant**, and the live run is what corrected them. Measured there:
  the bare `exploitable` flag is accepted and ignored (true and false both returned all 1,974
  plugins); the supported filter splits them 875 + 1,099. `severity=["critical","high"]` now
  returns 583 plugins instead of an empty set. `tvm_export_mttr` produced the same duration
  as Tenable's own `time_taken_to_fix` on 2,023 of 2,023 exported findings (largest gap 0.5
  seconds). That tenant is a small lab, so the MTTR batch guard fired โ€” 98% of fixes share
  eight scan dates โ€” which is the guard doing its job, not a defect.
- **The preflight was itself wrong the first time it met a live tenant.** It assumed the
  unfiltered query returns "everything" and that two halves of a filter add up exactly. Live,
  it reported a working severity filter as `unexpected`. In fact the default view hides `info`
  severity and `Fixed` findings (525 and 16 plugins here), a filter lifts only its own
  attribute's default, and the workbench returns one row per plugin, so a plugin with findings
  on both sides of a filter legitimately appears in both. The checks now compare against a
  baseline taken in the same mode, test for coverage rather than exact addition, and bound a
  multi-value filter between its largest value and its sum. The test suite's simulated tenant
  was rebuilt from those measurements; the old one had been designed around the assumptions
  that failed.
- **One figure was withdrawn.** An earlier `tvm_compare_scans` run reported 29.6% (123 fixed of
  415) and was quoted in drafts of this documentation. It could not be reproduced and came
  from a different key with a different view. On this tenant, over 90 days with info excluded,
  the figure is 6.1% (758 / 12,513), or 4.5% with info included โ€” a spread that is the reason
  `severity_scope` is now returned with every result.
- **A failed export could look like a clean environment.** `tvm_export_fetch(mode=
  "summary")` on a job that errored out mid-run returned a fully-formed, all-zero summary
  with no visible sign anything had failed โ€” reproduced live when an export hit the same
  `Insufficient scope` error a few seconds after being queued. The summary now sets
  `export_failed: true`, includes the underlying `error`, and leads with a note that the
  counts are not the environment's real totals.
- **Security Center has only been partially exercised against a live instance.** Three
  tools were confirmed working end to end โ€” `tsc_health`, `tsc_list_repositories`,
  `tsc_plugin_detail` โ€” with correct auth, error translation, and TLS handling. Every
  tool that calls `POST /rest/analysis` (`tsc_vuln_summary`, `tsc_search_vulns`,
  `tsc_search_assets`, `tsc_compare_scans`) could not be exercised on that instance: it
  returned a server-side error โ€” `SQLSTATE[HY000]: no such table: GroupLCE` โ€” on every
  analysis call regardless of query shape, which is that Security Center's own database
  missing a table its analysis engine expects to query, not something a client can work
  around. `tsc_list_queries`, `tsc_list_scans` and `tsc_scan_result_detail` also could not
  be exercised: they require an **organizational user**, and the credential available was
  an Administrator, which Security Center explicitly refuses for those calls
  ("Administrators may not manage Queries/Scans"). Request bodies for all of these were
  otherwise well-formed and reached the server correctly โ€” nothing failed on our side.
- **All 20 Tenable VM tools have been exercised against a live tenant and return real
  data**, including the two that were only code-reviewed at first pass โ€” `tvm_vuln_detail`
  and `tvm_vuln_outputs` โ€” plus workbench search, filter listing, and `tvm_compare_scans`
  (which correctly discovered `tracking.state` as this tenant's actual filter name).
  Identity Exposure has been exercised against a live tenant end to end. Cloud Security
  and PagerDuty are covered by unit tests against fixtures only โ€” smoke-test them against
  your own systems before relying on them.

## License

MIT โ€” see [LICENSE](LICENSE).