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

io.github.sabastiaz/tenable-mcp

by Sabastiaz

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

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:

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:

{
  "mcpServers": {
    "tenable-demo": {
      "command": "uvx",
      "args": [
        "--from", "git+https://github.com/Sabastiaz/tenable-honest-mcp",
        "tenable-mcp"
      ],
      "env": { "TENABLE_MCP_DEMO": "true" }
    }
  }
}
git clone https://github.com/Sabastiaz/tenable-honest-mcp.git && cd tenable-honest-mcp && uv sync
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"].

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.


Related MCP server: ESET PROTECT MCP Server

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

cp .env.example .env

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

# 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:

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

uv run pytest
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

    1. 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI assistants to query Microsoft Defender XDR telemetry for read-only threat hunting, incident triage, and vulnerability discovery using delegated per-user authentication and production guardrails.
    1
    MIT
  • A
    license
    B
    quality
    B
    maintenance
    Enables AI assistants to gain read-only visibility into an ESET PROTECT fleet via the ESET Connect API, covering devices, detections/EDR, vulnerabilities, patch status, and users.
    26
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to read protected computers and their check-ins, computer groups, approval requests, the unified audit log, and child organizations through a zero-trust endpoint security portal API. Every operation is read-only, with credentials supplied per request as headers and never stored.
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to ask Purple AI hunting questions, run PowerQueries over the Singularity Data Lake, and read alerts, vulnerabilities, misconfigurations and asset inventory from a SentinelOne console, plus VirusTotal and public CVE lookups. It is read-only and accepts per-request console credentials, making it stateless and multi-tenant.
    Apache 2.0