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.
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_scansets a definition'senabledflag 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.0the MCP endpoint is reachable by anything on the LAN, the transport has no authentication, and--allow-httpmeans 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.
Recommended Servers
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.
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.
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.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
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.
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.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
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.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.