Skip to main content
Glama
aqamahn

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

Install Server
A
license - permissive license
B
quality
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Provides AI agents with read access to Trane Tracer SC+ building automation systems for inventory and telemetry retrieval.
    Last updated
    8
    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.
    Last updated
    17
    MIT
  • A
    license
    -
    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.
    Last updated
    MIT

View all related MCP servers

Related MCP Connectors

  • Runtime permission, approval, and audit layer for AI agent tool execution.

  • CVE lookups (NVD) and dependency-manifest audits (OSV) for AI agents. No API keys.

  • CVE lookups (NVD) and dependency-manifest audits (OSV) for AI agents. No API keys.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aqamahn/MCP-server-Tenable-OT-V2'

If you have feedback or need assistance with the MCP directory API, please join our Discord server