Tenable OT Security MCP Server
# 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](https://developer.tenable.com/docs/ot-generate-an-api-key))
- 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:
```bash
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:
```bash
docker compose ps
```
```bash
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:
```bash
sudo apt install docker-compose-v2
```
…or skip Compose entirely — it isn't required:
```bash
docker build -t tenable-ot-mcp:0.1.0 .
```
```bash
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:
```bash
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
```bash
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:
```bash
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:
```json
{
"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:
```bash
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:
```bash
claude mcp add --transport http tenable-ot http://localhost:8000/mcp
```
### Option C — stdio (server run locally, no network at all)
```json
{
"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:
```bash
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:
```python
{"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
```bash
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](LICENSE). Every source file carries an
`SPDX-License-Identifier: Apache-2.0` header.
TDQS
Scored across 59 tools
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.
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.
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.
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.