Skip to main content
Glama
Tenable OT Security MCP Server

Tenable OT Security MCP Server

by aqamahn

Tenable OT Security MCP Server

An MCP (Model Context Protocol) server that connects AI clients — Claude Desktop, Claude Code, or any other MCP client — to a Tenable OT Security deployment. It exposes OT/ICS asset inventory, vulnerabilities, events, detection policies, and policy findings as read tools, plus an opt-in, dry-run-by-default write layer for asset edits, policy management, and finding resolution.

Not an official Tenable product. Copyright (c) 2026 Tenable, Inc., released under Apache 2.0. It is not a supported Tenable product: it ships with no SLA, no warranty, and no support commitment, and it is not covered by Tenable's product security or vulnerability-disclosure processes. Nothing here should be taken as a statement of Tenable's product roadmap. "Tenable" and "Tenable OT Security" are trademarks of Tenable, Inc.; Apache 2.0 explicitly does not grant trademark rights (§6), and no trademark licence is granted by this repository. Use at your own risk against your own deployment.


Safety model

This server talks to industrial control systems. Four rules are structural, not advisory:

1. Write tools are invisible unless you opt in. Without TENABLE_OT_WRITE_TOOLS_ENABLED=1, the write modules are never imported and no write tool name appears in the MCP tools/list response at all. An AI client cannot call — or even discover — an operation you haven't enabled. This is deliberately not a permission error at call time.

2. Every write defaults to dry_run=True. A dry-run call returns the exact payload that would be sent, without sending it. The AI is expected to show you that preview and get your approval before re-calling with dry_run=false.

3. Destructive operations need a second, explicit confirm flag. dry_run=false alone is not enough. delete_custom_field requires confirm_wipes_values=True on top of it, and is rejected without it.

4. Every write call is audited — dry-run, real, or failed. One JSON line per call to /data/audit.jsonl: timestamp, tool, parameters, dry-run flag, outcome, and any error. Mount that volume so the record outlives the container. The audit writer fails closed: if it can't record, the call raises rather than proceeding silently.

Two further guarantees:

  • No active scanning. Nothing in this codebase triggers, launches, or schedules a scan against live OT/ICS equipment. Scan definitions can be created and edited (behind the write opt-in, dry-run by default), and enable_active_scan sets a definition's enabled flag so it can participate in runs — but only a human, from the Tenable OT UI, ever starts a scan. There is no run/start/execute mutation anywhere in this codebase.

  • Credentials from the environment only. The API key is read from TOT_API_KEY, held privately by the client, and never logged, never written to disk by the server, and never included in an audit entry. Uploaded file contents are never audited either — only filename and byte size.


Related MCP server: Tenable Identity Exposure MCP Server

Prerequisites

  • A reachable Tenable OT Security deployment

  • A Tenable OT service-account API key (how to generate one)

  • Docker (for the container deployment), or Python 3.12+ to run it directly

  • Network reachability from wherever this runs to the appliance


Deploy

# on Ubuntu, if Docker isn't installed:
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
# log out/in or `newgrp docker`, then confirm:
docker ps

# from the dev machine:
scp -r "./tenable-ot-mcp" <vm-user>@<vm-ip>:~/tenable-mcp-server

# on the VM:
cd ~/tenable-mcp-server
docker compose build
docker compose up -d

Before docker compose up, create the .env file the compose file reads:

cp .env.example .env

Then edit .env and set TOT_URL and TOT_API_KEY. Keep the key out of shell history and out of version control — .env is gitignored.

Check it came up:

docker compose ps
docker compose logs -f

If docker compose isn't available

Ubuntu's docker.io package ships the Docker CLI without the Compose v2 plugin, so docker compose build fails with unknown command. Either install the plugin:

sudo apt install docker-compose-v2

…or skip Compose entirely — it isn't required:

docker build -t tenable-ot-mcp:0.1.0 .
docker run -d --name tenable-ot-mcp --restart unless-stopped --env-file .env -e TENABLE_OT_HOST=0.0.0.0 -e TENABLE_OT_PORT=8000 -e TENABLE_OT_AUDIT_DIR=/data -p 8000:8000 -v "$PWD/data:/data" --security-opt no-new-privileges:true tenable-ot-mcp:0.1.0

Audit-directory ownership (bind mounts)

The server process runs as non-root uid 10001, but a bind-mounted ./data arrives owned by your host uid (typically 1000) — so without help, the audit writer can't create audit.jsonl, and because it fails closed, every write call would then fail too. This is the single most common way this deployment breaks, and it breaks late: the container starts, reports healthy, serves reads fine, and only fails the first time something writes.

This is now fixed automatically, in the image itself, not by a manual step you have to remember to run. docker-entrypoint.sh starts as root for exactly long enough to chown/chmod /data if its ownership is wrong, proves the target uid can actually write there (a real write-then-delete probe, not just a permissions check), and then permanently drops to uid 10001 via setpriv before the server process ever starts — root is never carried into the running server. If the probe fails (e.g. a mount that ignores chown, such as some NFS/CIFS configurations), the container refuses to start and logs exactly what to run on the host, rather than starting a server whose every write will fail later. setpriv (from util-linux) is used instead of the more commonly documented gosu/su-exec because it's already present in the python:3.12-slim base image — no extra dependency.

You do not need to docker run --user 0:0 ... chown by hand anymore; a plain docker compose up -d (or docker run without a --user override) is enough on a fresh checkout, a fresh VM, or after the ./data directory gets recreated. The manual command below still works as a diagnostic if you ever want to fix ownership outside the container, e.g. before the image has even been built:

docker run --rm -v "$PWD/data:/data" --user 0:0 tenable-ot-mcp:0.1.0 sh -c "chown -R 10001:$(id -g) /data && chmod -R 770 /data"

Deploying without a deploy key

scripts/deploy_to_mcp_server.ps1 uses a dedicated SSH key for repeat deployments. It's a convenience, not a requirement — a plain scp -r of the project directory works fine. Copy into a path that doesn't already exist: scp -r into an existing directory nests the source inside it rather than replacing it, and will overwrite same-named files of anything already there.

Running without Docker

pip install .
export TOT_URL=https://tenable-ot.example.local
export TOT_API_KEY=...
python -m tenable_ot_mcp.server

Confirm what's exposed — especially whether the write layer is on:

python -m tenable_ot_mcp.server --list-tools

Configuration

All configuration is environment variables. There is no config file and no way to pass the API key on the command line.

Variable

Default

Purpose

TOT_URL

(required)

Appliance base URL. TENABLE_URL also accepted.

TOT_API_KEY

(required)

Service-account API key. TENABLE_API_KEY also accepted.

TENABLE_OT_WRITE_TOOLS_ENABLED

0

1/true/yes/on exposes the write layer. Anything else keeps it hidden.

TENABLE_OT_TRANSPORT

stdio

stdio, streamable-http, or sse. The image defaults to streamable-http.

TENABLE_OT_HOST / TENABLE_OT_PORT

127.0.0.1 / 8000

Bind address for HTTP transports.

TENABLE_OT_AUDIT_DIR

/data

Directory holding audit.jsonl.

TENABLE_OT_TLS_VERIFY

true (compose sets false)

Verify the appliance certificate. OT appliances are commonly self-signed — matching pyTenable's own default — so the compose file turns this off. Turn it on for any deployment with a trusted certificate.

TENABLE_OT_TIMEOUT

30

GraphQL request timeout (seconds).

TENABLE_OT_UPLOAD_TIMEOUT

180

File-upload timeout; uploads are parsed server-side and take far longer.

TENABLE_OT_TOKEN_LABEL

(unset)

Optional label stamped on every audit entry to identify which appliance/credential this instance runs as. A label — never the key.

TENABLE_OT_LOG_LEVEL

INFO

Log level. Logs go to stderr, keeping stdout clear for the stdio transport.


Connecting an MCP client

Option A — direct HTTP over the LAN (simplest)

Set TENABLE_OT_BIND=0.0.0.0 in .env and recreate the container, so it publishes on 0.0.0.0:8000->8000/tcp. Claude Desktop has no native plain-HTTP remote transport, so bridge it with mcp-remote. Because the connection is http:// rather than https://, --allow-http is required:

{
  "mcpServers": {
    "tenable-ot": {
      "command": "C:\\Program Files\\nodejs\\npx.cmd",
      "args": [
        "-y",
        "mcp-remote@0.1.37",
        "http://192.168.1.79:8000/mcp",
        "--allow-http",
        "--transport",
        "http-only"
      ]
    }
  }
}

A working bridge logs Connected to remote server, Local STDIO server running, Proxy established successfully.

Understand the trade-off. With TENABLE_OT_BIND=0.0.0.0 the MCP endpoint is reachable by anything on the LAN, the transport has no authentication, and --allow-http means the traffic — including every asset, vulnerability and policy record — is unencrypted on the wire. That is acceptable on an isolated lab segment and not much else. For anything beyond that, use Option B or put an authenticating TLS reverse proxy in front.

Option B — SSH tunnel (keeps it off the network)

Leave TENABLE_OT_BIND=127.0.0.1 (the default) so the port is bound to loopback on the server only, and forward it:

ssh -N -L 8000:127.0.0.1:8000 <vm-user>@<vm-ip>

Then point the client at http://localhost:8000/mcp — same mcp-remote config as above with the host changed, or for Claude Code:

claude mcp add --transport http tenable-ot http://localhost:8000/mcp

Option C — stdio (server run locally, no network at all)

{
  "mcpServers": {
    "tenable-ot": {
      "command": "python",
      "args": ["-m", "tenable_ot_mcp.server"],
      "env": {
        "TOT_URL": "https://tenable-ot.example.local",
        "TOT_API_KEY": "your-key-here"
      }
    }
  }
}

Tool catalog

130 tools: 59 read (always registered) and 71 write (only when TENABLE_OT_WRITE_TOOLS_ENABLED=1). Every write tool defaults to dry_run=True and is audited on every call. Confirm what your instance actually exposes with:

python -m tenable_ot_mcp.server --list-tools

Read tools (always available)

Orientation and connectivity

Tool

Description

tenable_ot_status

Connectivity/auth probe with latency; first stop when other tools fail.

summarize_environment

One-call orientation: asset/vulnerability/event/policy rollups for a new deployment.

get_schema_enums

Introspect this deployment's own GraphQL schema. Run this to unblock the gated filters.

Assets

Tool

Description

query_assets

Filter OT assets; returns identity, IPs/MACs, Purdue level, segments, backplane, risk. Paginated.

get_asset

One asset's full bundle by id, including tags and backplane id.

get_asset_vulnerabilities

Open vulnerabilities for one asset, with CVEs, CVSS, exploit/KEV flags, vendor solution.

list_custom_fields

The tenant's custom-field slot/label/type schema.

get_manual_asset_upload_status

Processing status of the last manual upload (CSV-type imports only — see the tool description).

Vulnerabilities and plugins

Tool

Description

query_vulnerabilities

Fleet-wide vulnerability search with severity / family / exploitability filters — "what do we patch first".

get_vulnerability

One vulnerability's full record.

query_plugin_definitions

Plugin definitions — the vulnerability catalog, not per-asset findings.

get_plugin_definition

One plugin definition by numeric id.

Events and detection policies

Tool

Description

query_events

OT events — individual detections, newest first (via pyTenable).

get_event

One OT event's full record.

list_detection_policies

Detection policies with severity, enabled/paused state, and fired-event counts.

get_policy

One policy's complete configuration. Required reading before update_policy.

query_policy_findings

Per-asset detection findings (policy × asset × hit counts).

Cross-domain joins

These return joined relational data, not computed analysis — the tool titles say so explicitly, so a model doesn't present a join as inference.

Tool

Description

query_attack_pathways

Attack-pathway data (relational, not computed).

query_vulnerability_clusters

Vulnerability clusters (relational join, not computed).

query_temporal_patterns

Temporal patterns (event sequence, not motif analysis).

get_asset_intelligence

Asset intelligence bundle — joined data, not narrative.

Topology, network configuration, sensors

Tool

Description

list_segments_and_zones

Network segments and zones as the appliance has observed them.

get_communication_paths

Observed communication paths for one asset.

list_network_config

What the appliance is configured to watch. kind selects: monitored_networks (adds a coverage block naming DISABLED subnets — blind spots), network_areas, network_interfaces, zones, tcp_port_pinnings, firewall_rules, ids_rules, baseline_policies. First stop when inventory looks wrong.

list_sensors

Sensors and OT agents feeding this appliance.

Impact analysis (run before writes)

Tool

Description

list_policy_relationships

"What breaks if I change this?" kind selects policies_containing or used_in_policies (both need target_id). Tenable exposes both directions — check both when the answer matters, because policy mutations are full replaces.

Groups — read

Tool

Description

list_asset_groups / list_archived_asset_groups / get_asset_group

Asset groups a policy can scope to. Call before update_policy to learn valid names.

list_email_groups / get_email_group

Email groups available as policy notification targets.

find_email_groups_using_smtp_server

Email groups routing through a given SMTP server.

list_schedule_groups / list_archived_schedule_groups / get_schedule_group

Schedule groups (time windows) a policy can use.

list_tag_groups / get_tag_group

Tag groups (PLC controller-tag rollups).

list_eligible_tags

Tags eligible for inclusion in a tag group.

list_rule_groups / list_archived_rule_groups / get_rule_group

Rule groups (IDS rule bundles).

list_port_groups / list_archived_port_groups / get_port_group

Port groups (port-range bundles for PortPolicy).

list_protocol_groups / list_archived_protocol_groups / get_protocol_group

Protocol groups (protocol + port-range bundles).

list_user_groups / list_archived_user_groups / get_user_group

User groups, ICP level.

list_em_user_groups / list_em_archived_user_groups / get_em_user_group

User groups, Enterprise Manager level.

Scan definitions — read

Definitions only. Nothing here runs a scan.

Tool

Description

list_active_scans

Active-scan definitions.

get_active_scan

One active-scan definition.

get_active_scan_executions

Past executions of an active scan.

Bulk exports (via pyTenable)

High latency — prefer the interactive query tools. Skipped entirely if no pyTenable bridge is supplied.

Tool

Description

export_assets

Bulk asset export.

export_plugin_definitions

Bulk plugin-definition export.

export_findings

Bulk export of finding instances (vulnerability/violation × asset).

Write tools (only when TENABLE_OT_WRITE_TOOLS_ENABLED=1)

All default to dry_run=True and are audited on every call.

Asset lifecycle

Tool

Risk

hide_asset / restore_asset

WRITE — reversible.

bulk_hide_assets / bulk_restore_assets

WRITE — affects many assets.

remove_assets_by_address

WRITE — destructive; queues entries for deletion. Re-discovery creates fresh records.

merge_assets

WRITE — DESTRUCTIVE AND IRREVERSIBLE. Source asset is permanently deleted. Polls the async merge job to a terminal state.

recalculate_asset_risk / recalculate_all_risk

WRITE — deployment-wide variant can be expensive.

upload_manual_asset_file

WRITE — creates/updates assets from file contents; .L5X uploads also run a location backfill.

Asset properties

Tool

Risk

update_asset

WRITE — edits name/kind/location/description/Purdue/criticality/custom fields.

bulk_edit_assets

WRITE — same edits across a filter. Untargeted calls rejected.

reset_asset_metadata

WRITE — reverts every operator-set field to as-discovered.

rename_backplane

WRITE — renames a chassis/rack (distinct object from an asset).

Custom-field schema

Tool

Risk

create_custom_field / rename_custom_field

WRITE.

delete_custom_field

WRITE — destructive. Wipes the stored value on every asset. Requires confirm_wipes_values=True.

Detection policies

Tool

Risk

enable_detection_policy / enable_detection_policies

WRITE.

disable_detection_policy / disable_detection_policies

WRITE — DISABLES DETECTION. Review blast radius.

archive_detection_policy / archive_detection_policies

WRITE — irreversible.

update_policy

WRITE — edits title/scope/schedule/severity/notifications. Always re-reads current state first, because Tenable's policy mutations are full replaces, not patches. Renaming (title) is live-confirmed on Activity (SrcDstSchema) policies only, including on system: true policies; other schemas raise a clear "unconfirmed" error rather than guessing.

Findings

Tool

Risk

resolve_findings

WRITE — marks findings resolved. Bare resolve-all is rejected.

Groups — write

Archive is Tenable OT's soft-delete. Run list_policy_relationships first: these are shared objects, and policy mutations are full replaces.

Tool

Risk

create_asset_group / update_asset_group / archive_asset_group

WRITE — asset groups are policy scope targets.

bulk_set_asset_group_display_tag

WRITE — bulk display-tag flag toggle.

create_email_group / update_email_group / archive_email_group

WRITE — alert recipient lists; archiving can silence notifications.

create_schedule_group / update_schedule_group / archive_schedule_group

WRITE — policy time windows.

create_tag_group / update_tag_group / archive_tag_group

WRITE — PLC controller-tag rollups.

create_rule_group / update_rule_group / archive_rule_group

WRITE — IDS rule bundles.

create_port_group / update_port_group / archive_port_group

WRITE — port-range bundles.

create_protocol_group / update_protocol_group / archive_protocol_group

WRITE — protocol + port-range bundles.

create_user_group / edit_user_group / archive_user_group / set_user_groups

WRITE — ACCESS CONTROL (ICP level).

create_em_user_group / edit_em_user_group / archive_em_user_group / set_em_user_groups

WRITE — ACCESS CONTROL (Enterprise Manager level).

Scan definitions — write

Define/edit only. enable_active_scan sets the enabled flag so a definition can participate in runs initiated from the Tenable OT UI — it does not run the scan. No tool in this codebase starts a scan.

Tool

Risk

define_active_scan / edit_active_scan

WRITE — creates/edits a definition; does not run it.

enable_active_scan / disable_active_scan

WRITE — toggles whether a definition participates in UI-initiated runs.

delete_active_scan

WRITE — irreversible; removes the definition.

define_port_scan / edit_port_scan

WRITE — port-scan job definition.

define_snmp_scan / edit_snmp_scan

WRITE — SNMP scan job definition.

define_controller_discovery_scan / edit_controller_discovery_scan

WRITE — controller-discovery job definition.

define_asset_discovery_scan / edit_asset_discovery_scan

WRITE — asset-discovery job definition.

define_inactive_probing_scan / edit_inactive_probing_scan

WRITE — inactive-probing job definition.

edit_subnets_discovery_scan

WRITE — edits the subnets-discovery scan.

Sensors

Tool

Risk

manage_sensor

WRITE — CHANGES WHAT THE APPLIANCE CAN SEE. Pausing or removing a sensor creates a monitoring blind spot.


Current limitations

Filtering works, except where it needs an unconfirmed enum value.

The filter-expression input shape is confirmed — taken from pyTenable's own source (tenable/ot/exports/api.py, tenable/ot/assets.py), which builds the same AssetExpressionsParams inputs:

{"field": "id", "op": "Equal", "values": asset_id}          # leaf
{"op": "And", "expressions": [...]}                          # conjunction

So these filters work today: vendor, name_contains, hidden, tags, every query_policy_findings filter (including severity_at_least, since PolicyLevel is confirmed), and the equivalent targeting on bulk_edit_assets and resolve_findings.

Still gated: filters that map a natural word onto a Tenable enum value — kind (AssetType), category (AssetCategory), criticality_at_least (UserDefinedCriticality), and the purdue_level / kind write-side setters. Those enum values aren't published anywhere, and this server refuses to guess them: a wrong enum doesn't fail loudly, it silently filters or writes the wrong thing. Calling one raises SchemaNotConfirmedError naming the exact type to introspect.

To close the gap, run the introspection query in src/tenable_ot_mcp/tools/_enums.py (_INTROSPECTION_QUERY) against your deployment and fill in the UNCONFIRMED section — a single-block edit. The types still needed are listed there as _NEEDED_TYPES.

Coverage note. Group management (asset / email / schedule / tag / rule / port / protocol / user), network topology, network configuration, sensors, and scan definitions are all implemented against custom GraphQL, because pyTenable does not cover them — tenable.ot.TenableOT wraps only .assets, .events, .plugins, .exports and a generic .graphql() passthrough (verified against pyTenable 26.6.1). pyTenable is used only for the bulk-export surface (export_assets, export_findings, export_plugin_definitions) and the event/plugin queries; if no pyTenable bridge is supplied those tools are skipped and the server still starts.

Queries marked unconfirmed in the source return a clear error envelope naming what to introspect, rather than raising, when a given appliance doesn't expose them.


Development

pip install -e ".[dev]"
python -m pytest

The suite runs entirely against a mocked client — no network, no appliance, no credentials. It covers dry-run paths, live paths, audit-entry assertions, and confirm-flag rejection paths for destructive tools.

For a real deployment check, scripts/live_smoke_test.py exercises read-only tools against a live appliance using env-var credentials. It never registers the write modules. It is deliberately not part of the test suite and must not run in CI.

scripts/bulk_import_projects.py batch-imports Rockwell .L5X/.ACD project files by calling the real upload_manual_asset_file tool in-process, so batch behaviour can't drift from single-file behaviour. Defaults to a dry run; pass --live to actually upload.


Licence

Apache License 2.0 — see LICENSE. Every source file carries an SPDX-License-Identifier: Apache-2.0 header.

Available Tools

59 tools
export_assetsBulk export assetsA

Bulk asset export via Tenable OT's export API. Returns full asset records for offline analysis, inventory reconciliation, or feeding another system.

PREFER query_assets for interactive questions — it is far lower latency, supports the natural-vocabulary filters, and returns a trimmed projection that is cheaper to read. Use this tool when you genuinely need whole records in bulk.

search is a single-term substring. Structured export filters take a filter shape pyTenable's own documentation does not specify, so they are not exposed rather than guessed.

READ-ONLY. High latency on large deployments.

Inputs: limit, search, sort_by, start_at, +1 more. Returns: object

export_findingsBulk export findingsA

Bulk export of FINDING INSTANCES — one record per (vulnerability or policy violation x affected asset) — via Tenable OT's official export API.

Choosing between the finding tools: • get_asset_vulnerabilities — the open vulnerabilities on ONE asset. Lowest latency; start here for a specific asset. • query_policy_findings — detection-policy findings, with severity/status/MITRE filters. • this tool — every finding instance in bulk, for offline analysis or reporting across the whole deployment.

search is a single-term substring. Structured export filters take a filter shape pyTenable's documentation does not specify, so they are not exposed rather than guessed.

READ-ONLY. High latency on large deployments.

Inputs: limit, search, start_at. Returns: object

export_plugin_definitionsBulk export plugin definitionsA

Bulk plugin-definition export via Tenable OT's export API. Returns full plugin records in bulk.

PREFER query_plugin_definitions for interactive lookups. Use this when you need the whole catalog.

READ-ONLY. High latency.

Inputs: limit, search, start_at. Returns: object

find_email_groups_using_smtp_serverFind email groups that route through a given SMTP serverA

Given an SMTP-server id, return the email groups bound to it. Useful before retiring an SMTP server: any group returned here will lose its delivery path if the server is removed.

Inputs: smtp_server_id (required), limit, after_cursor. Returns: object

get_active_scanGet one active scanA

Returns the full specification for one active-scan job by id. Use after list_active_scans returns a job of interest, or when reading parameters before suggesting modifications.

Inputs: scan_id (required). Returns: object

get_active_scan_executionsGet past executions of an active scanA

Returns past execution records for one active scan: start/end time, elapsed time, status (Completed / Failed / Ongoing), who initiated, source (UI / API / system), and any failure explanation. Use this to audit when a scan was last run and whether it succeeded — but the underlying execution is triggered by humans, not this server.

Inputs: scan_id (required), limit. Returns: object

get_assetGet one OT assetA

Returns the per-asset bundle for one OT asset by id: identity, classification, IPs / MACs, Purdue level, segment membership, criticality, run status, backplane (rack) membership, tags, and aggregate risk. backplane is null for assets not mounted in a chassis; when present, its id is what rename_backplane needs (a backplane's id is NOT the same as any asset's id). tags includes any Tenable-assigned project-file-import tag ('Project (SN: )') — pass one back into query_assets' tags filter to pull every asset that same import produced. Call after query_assets returns an id of interest. For the asset's open vulnerabilities call get_asset_vulnerabilities separately.

Inputs: asset_id (required). Returns: object

get_asset_groupGet an asset group by idA

Fetch one asset group's full record by its id. The shape varies by group subtype: an AssetList returns an assets_sample preview, an IpRange returns start_ip / end_ip, a FilterGroup returns its filter expression, and so on.

Inputs: group_id (required). Returns: object

get_asset_intelligenceGet asset intelligence bundle (joined data, not narrative)A

Returns one asset's full relational bundle in a single shot: asset core + open vulnerabilities + recent events where the asset is source or destination + 1-hop comms peers. The AI uses this bundle to write a per-asset intelligence narrative if asked. The server does NOT generate the narrative itself.

Inputs: asset_id (required), max_peers, max_vulns, max_events, +1 more. Returns: object

get_asset_vulnerabilitiesGet vulnerabilities for one assetA

Returns the open vulnerabilities (Tenable plugins) affecting one OT asset. Each vulnerability includes CVEs, CVSS v3 score and vector, exploit availability flags (CISA KEV, exploit-available, exploited-by-malware), age, public disclosure date, and the official vendor solution. Use query_vulnerabilities for global vuln filtering (KEV-only, exploit-available, severity floor, etc.); this tool is asset-scoped.

Inputs: asset_id (required), limit. Returns: object

get_communication_pathsGet communication paths for an assetA

Returns observed L2 communication links involving one OT asset, with peer asset id, protocols seen, traffic / conversation count, and first/last-conversation times. Peer assets are returned as IDs only — call get_asset on each peer id to enrich with name / vendor / type. The consuming AI uses this as the graph adjacency for attack-path reasoning; call again on a peer's id to expand further.

Inputs: asset_id (required), limit, since. Returns: object

get_email_groupGet an email group by idA

Fetch one email group with its recipient list and bound SMTP server details.

Inputs: group_id (required). Returns: object

get_em_user_groupGet a user group by id (Enterprise Manager level)C

One EM-level user group with its roles and member preview.

Inputs: group_id (required). Returns: object

get_eventGet one OT eventA

Returns full detail for a single OT event by id. Use after query_events returns an id of interest, or when a finding or case references a specific event.

Inputs: event_id (required). Returns: object

get_manual_asset_upload_statusGet manual asset-upload processing statusA

Check the processing status of the most recent file uploaded via Tenable OT's Manual Uploads data source (upload_manual_asset_file). Processing is asynchronous: poll this a few times after uploading if it hasn't updated yet. Returns null fields if no manual upload has ever been processed on this deployment.

CAVEAT (confirmed empirically): this field tracks literal CSV-type imports specifically — it stayed null through a live Rockwell .L5X project-file upload that DID create an asset. For project-file / OTD-scan-result uploads, don't treat a null result here as failure — confirm outcome with query_assets (by the controller/device name you expect, or by IP) instead of waiting on this to populate.

Inputs: no named parameters. Returns: object

get_plugin_definitionGet one plugin definitionA

Returns one plugin definition by its NUMERIC plugin id, including the assets it affects. Note the id is an integer (e.g. 500123), not the UUID used for assets.

READ-ONLY.

Inputs: plugin_id (required). Returns: object

get_policyGet one policy's full configurationA

Fetch one policy's COMPLETE configuration by id — source/destination asset-group scope (as AND'd rows of OR'd include/exclude conditions), schedule, severity, notification actions (syslog server / email group), and (for Communication/Protocol-schema policies) the protocol/port group. Unlike list_detection_policies's summary, this returns every field the policy's edit surface exposes.

Call this BEFORE update_policy — Tenable OT's policy mutations are full replaces, not patches, so you need to see the current state of every field before changing just the ones you actually want to change.

Inputs: policy_id (required). Returns: object

get_port_groupGet a port group by idC

One port group with up to 100 of its port-range items.

Inputs: group_id (required). Returns: object

get_protocol_groupGet a protocol group by idC

One protocol group with up to 100 of its items.

Inputs: group_id (required). Returns: object

get_rule_groupGet a rule group by idC

One rule group with the first 25 included rules.

Inputs: group_id (required). Returns: object

get_schedule_groupGet a schedule group by idA

Fetch one schedule group. Shape varies by kind: TimeInterval returns start_time/end_time; RecurringGroup returns a list of weekly windows under schedules.

Inputs: group_id (required). Returns: object

get_schema_enumsIntrospect the Tenable OT GraphQL schemaA

Read this deployment's own GraphQL schema — enum values, input-object shapes, and the available root query/mutation fields.

WHY THIS MATTERS: some filters on this server refuse to run with a SchemaNotConfirmedError because their Tenable-side enum values were never confirmed (this server does not guess enum values — a wrong one silently filters or writes the wrong thing instead of failing). Call this tool with no arguments to dump exactly the types those mappings need, then hand the output to whoever maintains tools/_enums.py; filling them in is a single edit and unblocks the gated filters.

It also answers 'does this appliance actually expose query X?' — useful when a topology tool reports that a top-level query was rejected.

Arguments: • type_names: specific GraphQL types to dump. Omit to get the types this server still needs. • include_inputs: also dump input-object field shapes (for filter/mutation argument structures). • list_root_fields: also list every root query and mutation name the appliance exposes.

READ-ONLY. Touches no OT data — GraphQL reflection only.

Inputs: type_names, include_inputs, list_root_fields. Returns: object

get_tag_groupGet a tag group by idB

One tag group with up to 100 member items.

Inputs: group_id (required). Returns: object

get_user_groupGet a user group by id (ICP-level)B

One ICP-level user group with its roles and member preview.

Inputs: group_id (required). Returns: object

get_vulnerabilityGet one vulnerabilityA

Returns one Tenable plugin (vulnerability) by id, plus the full list of OT assets currently affected. Use this to reason about exposure breadth ('which assets are affected by this exploited-in-the-wild vuln?'). To look up by CVE id, call query_vulnerabilities(cve='CVE-...') first and then fetch by the returned plugin_id.

Inputs: plugin_id (required). Returns: object

list_active_scansList active scansA

Returns Tenable OT active-scan job specifications: name, description, scan operation type (PortScan, AssetDiscovery, SnmpType, etc.), category (IT / OT / Discovery), trigger (Manual / Periodic / System), enabled flag, status, and the asset group the job targets. Use this to audit what scans are configured. Predefined system scans appear with predefined: true. Note: this server does not expose any tool that runs a scan — that's a human-only action via the Tenable OT UI.

Inputs: limit. Returns: object

list_archived_asset_groupsList archived asset groupsA

Page through archived asset groups — those that were soft-deleted via archive_asset_group. Group definitions and historical membership are preserved by Tenable; they just stop appearing in active views.

Inputs: limit, after_cursor. Returns: object

list_archived_port_groupsList archived port groupsC

Soft-deleted port groups, paginated.

Inputs: limit, after_cursor. Returns: object

list_archived_protocol_groupsList archived protocol groupsC

Soft-deleted protocol groups, paginated.

Inputs: limit, after_cursor. Returns: object

list_archived_rule_groupsList archived rule groupsC

Soft-deleted rule groups, paginated.

Inputs: limit, after_cursor. Returns: object

list_archived_schedule_groupsList archived schedule groupsC

Soft-deleted schedule groups, paginated.

Inputs: limit, after_cursor. Returns: object

list_archived_user_groupsList archived user groups (ICP-level)B

Soft-deleted user groups at the ICP level.

Inputs: limit, after_cursor. Returns: object

list_asset_groupsList asset groups (active)A

Page through every active (non-archived) asset group in the deployment. Each entry includes its membership shape — IP list, IP range, asset-id list, filter expression, etc. — and whether it surfaces as a UI tag (display_tag). Use this before creating to avoid duplicating an existing group.

Inputs: limit, after_cursor. Returns: object

list_custom_fieldsList configured custom fieldsA

Returns the asset custom-field schema for this Tenable OT tenant. Custom fields are 10 fixed slots ('customField1'..'customField10'); operators map each slot to a human label (e.g. 'Plant ID', 'CDA Type') and a value type ('PlainText' or 'HyperLink').

Call this before reading or writing custom-field values so the AI knows which labels are configured. Read tools (get_asset, query_assets) already surface values keyed by label automatically; write tools (update_asset, bulk_edit_assets) accept custom_fields keyed by label and translate to slots internally.

Inputs: no named parameters. Returns: object

list_detection_policiesList detection policiesA

Returns OT detection policies — the rules that fire events. Each policy has a level (severity), enabled / paused / archived flags, an event-type classification, and aggregate fired-event counts. Use this to audit which policies are configured, which are noisy, or which are paused. Call query_policy_findings for the per-asset hits one policy is producing.

Note: Tenable OT's policies query supports pagination only — the category / enabled / search filters below are applied client-side after fetching a page. For large deployments, increase limit to capture more before filtering.

Inputs: limit, paused, search, enabled, +1 more. Returns: object

list_eligible_tagsDiscover tags eligible for a tag groupA

List controller tags that could be added to a tag group. Filterable by asset (asset_id) and tag-value type (tag_type: one of Unknown, Int, Bool, Short, DInt, Long, Float, MultipleTagTypes). Use this before create_tag_group to discover what's available without guessing tag ids.

Inputs: limit, asset_id, tag_type, after_cursor. Returns: object

list_email_groupsList email groupsA

Page through every email group in the deployment. Each entry includes its recipients, the SMTP server it routes through, and last-modified metadata. Email groups are referenced by detection policies' actions to send alert emails.

Inputs: limit, after_cursor. Returns: object

list_em_archived_user_groupsList archived user groups (Enterprise Manager level)D

Soft-deleted EM-level user groups.

Inputs: limit, after_cursor. Returns: object

list_em_user_groupsList user groups (Enterprise Manager level)A

Page through every active EM-level user group. Each entry exposes em_level (whether the group is EM-only) plus roles and member preview.

Inputs: limit, after_cursor. Returns: object

list_network_configRead what the appliance is configured to monitorA

Returns the appliance's network CONFIGURATION — what it is set up to watch, as opposed to list_segments_and_zones and get_communication_paths, which report what it has actually observed.

Reach for this when inventory looks wrong. If an asset is missing from query_assets, the usual cause is that its subnet isn't an enabled monitored network — nothing was ever captured, so the asset cannot exist in the data.

kind selects what to read: • monitored_networks — subnets the appliance captures. Adds a coverage block naming any DISABLED subnets, which are blind spots • network_areas — named groupings, incl. whether each supports active queries • zones — the coarsest grouping, above segments • firewall_rules — appliance firewall operations/targets • tcp_port_pinnings — IP/port pinning rules that force protocol interpretation • network_interfaces — interfaces seen, by MAC and family

READ-ONLY.

Inputs: kind (required), after, limit, target_id. Returns: object

list_policy_relationshipsCheck what references an object before changing itA

Answers 'what breaks if I change this?' — the impact check to run BEFORE any write that touches a shared object (asset group, schedule group, port/protocol group, baseline).

Tenable OT's policy mutations are FULL REPLACES, so an edit lands completely and silently. If a live detection policy scopes to the group you're about to archive or re-scope, that policy's coverage changes with no warning. This tool surfaces those dependants first.

kind selects the relationship: • policies_containing — policies that CONTAIN the given object id (needs target_id) • used_in_policies — policies that USE the given object id (needs target_id). Tenable exposes both directions; check both when the answer matters. • baseline_policies — policies bound to the network baseline (no id needed) • ids_rules — the IDS rule catalogue with SIDs, classifications and enabled state (no id needed)

The impact block gives the verdict: how many referencing policies are actually LIVE (not disabled/archived/paused), and their titles. An archived policy referencing the object is not a reason to hold back; a live one is.

READ-ONLY.

Inputs: kind (required), after, limit, target_id. Returns: object

list_port_groupsList port groupsA

Page through every active port group. Port groups are reusable port-range bundles consumed by PortPolicy definitions.

Inputs: limit, after_cursor. Returns: object

list_protocol_groupsList protocol groupsA

Page through every active protocol group. Each item carries a protocol (TCP/UDP/MODBUS/S7/IEC104/DNP3/etc.) and optional port range.

Inputs: limit, after_cursor. Returns: object

list_rule_groupsList rule groupsA

Page through every active rule group. A rule group is a bundle of IDS rule SIDs referenced by IntrusionPolicy. Each entry includes a preview of up to 25 included rules.

Inputs: limit, after_cursor. Returns: object

list_schedule_groupsList schedule groupsA

Page through every active schedule group. Each entry surfaces its kind (one-shot TimeInterval, weekly RecurringGroup, or system ScheduleFunction) and the windows it defines. Every policy mutation's schedule argument resolves against one of these.

Inputs: limit, after_cursor. Returns: object

list_segments_and_zonesList network segments and zonesA

Returns Tenable OT's segmentation: every segment (with VLAN, subnet, asset-type filter, system flag, archived flag) and every zone (a higher-level grouping of asset groups). Use this to answer compliance questions about the IEC 62443 Zone & Conduit model, NERC CIP Electronic Security Perimeters, or NEI 08-09 defense-in-depth.

Inputs: no named parameters. Returns: object

list_sensorsList sensorsA

Returns every Tenable OT sensor in the deployment with its current status, connection / tunnel status, version, addressing, error state, and whether updates are pending. Use this to verify visibility coverage before drawing conclusions from query_assets / query_events — an offline sensor means absence of evidence, not evidence of absence.

The status filter applies client-side after fetch.

Inputs: status, connection_status. Returns: object

list_tag_groupsList tag groupsA

Page through every tag group. Tag groups bundle controller tags (by asset id + tag id) so a TagValuePolicy can fire against all members. tag_type indicates the scalar type Tenable evaluates the tag values as.

Inputs: limit, after_cursor. Returns: object

list_user_groupsList user groups (ICP-level)A

Page through every active user group at the ICP level. Each entry exposes its assigned roles and a sample of member users.

Inputs: limit, after_cursor. Returns: object

query_assetsQuery OT assetsA

Returns a list of OT assets in the Tenable OT deployment, filtered by the provided criteria. Returns up to limit (max 500) assets with each asset's identity, classification, IPs / MACs, Purdue level, segment membership, backplane (rack) membership, and aggregate risk. Call get_asset on a returned id for the per-asset bundle, or get_asset_vulnerabilities for that asset's open vulnerabilities.

total_count is the full number of assets matching the filter, independent of the page size — use it to answer 'how many' questions directly. When the match exceeds one page the response sets has_more: true and returns an end_cursor; pass that as after to fetch the next page, repeating until has_more is false to walk the entire matched set.

Filter values use natural OT vocabulary: • kind: one of ['accesscontrolsystem', 'accesspoint', 'actuator', 'backplanemodule', 'barcodescanner', 'bms', 'camera', 'cnc', 'controller', 'converter', 'cp', 'datalogger', 'dcs', 'domaincontroller', 'drive', 'eng', 'fielddevice', 'fileserver', 'firewall', 'gateway', 'historian', 'hmi', 'hub', 'hvacmodule', 'ied', 'industrialgateway', 'industrialnetworkdevice', 'industrialprinter', 'industrialrouter', 'industrialsensor', 'industrialswitch', 'inverter', 'io', 'iot', 'lightingcontrol', 'medicaldevice', 'mobile', 'networkdevice', 'otdevice', 'otserver', 'otworkstation', 'panel', 'plc', 'powermeter', 'powersupply', 'printer', 'projector', 'radio', 'relay', 'remoteio', 'repeater', 'robot', 'router', 'rtu', 'securityappliance', 'sensor', 'serialethernetbridge', 'server', 'smarthub', 'smartsensor', 'smarttv', 'storagedevice', 'switch', 'tablet', 'tenableem', 'tenableicp', 'tenablesensor', 'threedprinter', 'unknown', 'ups', 'videomanagementsystem', 'virtualserver', 'virtualworkstation', 'voipdevice', 'webserver', 'workstation'] • category: one of 'controller', 'network', 'iot' • criticality_at_least: one of ['none', 'low', 'medium', 'high'] • vendor: equal-match on the vendor name • name_contains: substring match on the asset name • tags: list of exact tag strings; matches an asset carrying ANY of them. Project-file uploads (upload_manual_asset_file) are auto-tagged by Tenable as 'Project (SN: )' — call get_asset on one known asset from an import to read its exact tag, then use it here to pull every asset that same import produced. This is the reliable way to disambiguate duplicate-named modules from different imports (e.g. a redundant PLC pair, each with its own project file, producing two identically-named 'EN01' assets that are only distinguishable by which import's tag they carry) — module name and description alone can't tell them apart.

Inputs: kind, tags, after, limit, +6 more. Returns: object

query_attack_pathwaysQuery attack-pathway data (relational, not computed)A

Returns the asset's 1-hop network neighborhood: the asset itself plus a list of peer-asset IDs it has communicated with, with the protocols and conversation count of each link. Use this AS THE GRAPH the AI walks to reason about attack paths — call again on each peer's id to expand further. The server does NOT compute paths, pick highest-risk routes, or score compromise time. That's the AI's job. Peer assets are returned as IDs only — call get_asset on each to enrich with name / vendor / type.

Inputs: entry_asset_id (required), max_peers. Returns: object

query_eventsQuery OT eventsA

Returns OT detection events matching the filter criteria, newest first. Use this to investigate alert windows, policy-firing patterns, source/dest IP context, or events in a specific time window. Each event includes the time, classification, severity, source/dest assets, the firing detection policy, and protocol/IP/MAC context. Call get_event on a returned id for full event detail.

total_count is the full number of events matching the filter, independent of the page size — use it to answer 'how many' questions directly. When the match exceeds one page the response sets has_more: true and returns an end_cursor; pass that as after to fetch the next page, repeating until has_more is false to walk the entire matched set. Event totals can be very large, so narrow with the filters (severity, time window, policy) before paging.

Filter values use natural OT vocabulary: • severity_at_least: one of 'none', 'low', 'medium', 'high' • event_type: a PolicyEventType name like 'FirmwareVersionChange', 'ConfigurationDownload', 'ProgrammingUpload', 'OperatingMode' • since / until: ISO-8601 timestamps for time-window scope

Inputs: after, limit, since, until, +7 more. Returns: object

query_plugin_definitionsQuery plugin (vulnerability) definitionsA

Returns Tenable plugin DEFINITIONS — the vulnerability checks themselves (id, name, family, severity, VPR score, and how many assets each affects). This is the catalog, NOT per-asset findings: a plugin appears here whether or not anything in your environment is affected.

For the vulnerabilities on a specific asset use get_asset_vulnerabilities. For a bulk dump of every finding instance use export_findings.

search is a single-term substring match. Structured field filters need a filter-expression shape not yet confirmed against a live deployment, so they are not exposed rather than guessed.

READ-ONLY.

Inputs: limit, search, sort_by, start_at, +1 more. Returns: object

query_policy_findingsQuery policy findingsA

Returns per-asset findings for one or more detection policies — i.e. the rows of (policy × asset × hit count) that the policies have produced. Use this to see which assets keep tripping a policy (often a tuning gap), or which assets are MITRE-mapped to a specific technique. Each finding has firstHitTime / lastHitTime, activeHits / resolvedHits, status, and joined source / destination assets.

Filter values use natural OT vocabulary: • severity_at_least: one of 'none', 'low', 'medium', 'high' • status: a FindingStatus value (e.g. 'Open', 'Resolved') • mitre_technique: a MITRE ATT&CK id (e.g. 'T1565.001') • since: ISO-8601 timestamp; findings last-seen at or after

Inputs: limit, since, search, status, +4 more. Returns: object

query_temporal_patternsQuery temporal patterns (event sequence, not motif analysis)A

Returns events in a time window, ordered chronologically, with their classification, firing policy, and source/dest IPs joined. The AI uses this raw sequence to detect patterns (e.g. config-download + firmware-change + operating-mode-change within minutes = high-priority investigation). The server does NOT detect motifs, score patterns, or label sequences.

Inputs: since (required), limit, until, event_types. Returns: object

query_vulnerabilitiesQuery OT vulnerabilitiesA

Returns Tenable plugins (vulnerabilities) matching the filter criteria. Each result includes CVEs, CVSS v3 score and vector, exploit availability flags (CISA KEV, exploit-available, exploited-by-malware), age, public disclosure date, and the official vendor solution. Call get_vulnerability on a returned plugin id to see every affected asset.

total_count is the full number of plugins matching the filter, independent of the page size — use it to answer 'how many' questions directly. When the match exceeds one page the response sets has_more: true and returns an end_cursor; pass that as after to fetch the next page, repeating until has_more is false to walk the entire matched set.

Filter values use natural OT vocabulary: • severity_at_least: one of 'info', 'low', 'medium', 'high', 'critical' • cve: a CVE substring (e.g. 'CVE-2023-25619' or 'CVE-2023' for a year-bucket) • family / source: exact-match plugin metadata

For KEV-only or exploit-available filtering, inspect the projected flags in the response — those live on plugin details and aren't filterable server-side.

Inputs: cve, after, limit, family, +3 more. Returns: object

query_vulnerability_clustersQuery vulnerability clusters (relational join, not computed)A

Returns the per-asset → vulnerabilities join the consuming AI uses to spot common CVEs across multiple assets, exploit chains (KEV + exploit-available + high criticality), or single-patch leverage points (one CVE fixing many). Two modes:

• Pass asset_ids: parallel per-asset traversal of asset.plugins, returning each asset's vulns with the same schema. The AI walks the result to find shared CVEs. • Pass cve_substring only: global plugin search (e.g. 'CVE-2023' for a year-bucket), each plugin coming with its full affectedAssets list joined.

Both args may be combined for a per-asset CVE-filtered view. The server does NOT cluster server-side.

Inputs: asset_ids, global_limit, cve_substring, per_asset_limit, +1 more. Returns: object

summarize_environmentSummarize the OT environmentA

Returns a one-shot snapshot of the operator's OT environment — total counts and useful subtotals across assets, events, vulnerabilities, sensors, topology, and policies. Ideal as the first call when the AI doesn't yet know the deployment's shape and scale. Each section is a compact dict; subtotals are best-effort (only those Tenable's filters support are populated).

Asset criticality buckets: none / low / medium / high. Plugin severity buckets: info / low / medium / high / critical. Event subtotals split by resolved flag.

Inputs: no named parameters. Returns: object

tenable_ot_statusCheck Tenable OT connectivityA

Report whether this server can reach and authenticate to the configured Tenable OT deployment, with round-trip latency and, on failure, the underlying error. Call this first when other tools are failing, to tell an appliance/credential problem apart from a query problem.

READ-ONLY. Never returns the API key.

Inputs: no named parameters. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3.2/5.0

    Scored across 59 tools

    Disambiguation4/5

    Most tools have clearly distinct purposes, but the large number of query/list variants (e.g., query_attack_pathways vs get_communication_paths, query_vulnerabilities vs query_vulnerability_clusters) creates some potential for misselection. Descriptions are detailed enough to resolve most ambiguity.

    Naming Consistency5/5

    Tool names follow a consistent snake_case verb_noun pattern (list_, get_, query_, export_, find_), with only a few harmless outliers like summarize_environment and tenable_ot_status. The naming convention is highly predictable across the entire set.

    Tool Count2/5

    At 59 tools, the set is far beyond the recommended 3-15 range and feels heavy, with many archived variants and overlapping query tools. While the OT security domain is broad, the count is excessive and could overwhelm an agent.

    Completeness2/5

    The read-only surface is broad, but the server references write tools like update_policy and create_tag_group that are not actually provided, leaving lifecycle gaps. There is also no tool to execute scans, only to view scan configurations. The missing write operations and scan execution are significant gaps.

    Maintenance

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      Provides AI agents with read access to Trane Tracer SC+ building automation systems for inventory and telemetry retrieval.
      8
      1
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      Exposes the Tenable Identity Exposure (TIE) REST API as tools for LLM clients, enabling queries on security profiles, activity timelines, AD objects, and more.
      15
      5
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      Exposes Tenable security operations as MCP tools for AI-powered security workflows, enabling asset, vulnerability, scan, plugin, and tag management via natural language.
      17
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI assistants to query and manage OpenCTI threat intelligence data, including indicators, observables, reports, malware, and more, with read-only and optional write operations.
      1
      MIT