Tenable OT Security MCP Server

Tenable OT Security MCP Server

Connects AI clients to Tenable OT Security, exposing OT/ICS asset inventory, vulnerabilities, events, policies, and findings as read tools, with an opt-in dry-run write layer for asset edits and policy management.

Category
Visit Server

README

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.

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 valuekind (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.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured