wago-plc-mcp-server
Talk to your WAGO PLC fleet the way you'd talk to a colleague. Ask an AI assistant to read, configure, and monitor your controllers in plain English - no scripts, no parameter IDs to memorize.
Choose your path
| I am a... | I want to... | Start here |
|---|---|---|
| Claude Desktop / Claude Code user | Connect my AI assistant to WAGO PLCs and start asking questions | Part 1 → Quick Start → What can I ask it?, or the one-file Claude Desktop extension |
| Automation / OT engineer | Understand what this does to my PLCs and whether it's safe | Part 2 → What this does and doesn't do |
| Software / DevOps engineer | Deploy this in production with GitOps, TLS, and audit logging | Part 3 → Production deployment → GitOps write-gate |
Each part is self-contained top to bottom - a Claude Desktop user never needs to read the GitOps internals, and a DevOps engineer never needs the chat examples. Reference at the end covers cross-cutting material (FAQ, raw curl access, CRA compliance) that applies regardless of persona.
Architecture
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor':'#1F2837',
'primaryTextColor':'#ffffff',
'primaryBorderColor':'#6EC800',
'lineColor':'#6EC800',
'secondaryColor':'#EFF0F1',
'secondaryTextColor':'#1F2837',
'secondaryBorderColor':'#A5A8AF',
'tertiaryColor':'#FFFFFF',
'tertiaryTextColor':'#1F2837',
'tertiaryBorderColor':'#DEDFE1',
'fontFamily':'Segoe UI, Helvetica, Arial, sans-serif',
'clusterBkg':'#EFF0F1',
'clusterBorder':'#A5A8AF',
'edgeLabelBackground':'#1F2837'
}}}%%
flowchart TB
subgraph Clients["AI clients (any MCP client works)"]
direction LR
CD("Claude Desktop<br/>(direct HTTP)")
CC("Claude Code<br/>(direct HTTP)")
OC("OpenClaw<br/>(direct HTTP)")
end
%% Legacy/offline path: wago_proxy.py bridges stdio to HTTP for Claude
%% Desktop clients too old to speak remote MCP directly - see Windows .exe
CD & CC & OC -- "Bearer token" --> MCP
subgraph Server["wago-plc-mcp-server - Docker, port 6042"]
direction LR
MCP("29 MCP tools<br/>find_parameters · get_parameter<br/>set_parameters · invoke_method<br/>create/read_watchlist · get_plc_audit_log · …")
Guard("Bearer auth · rate limiting<br/>hash-chained audit log")
MCP --- Guard
end
MCP --> FAN("WDA Bearer token + TLS<br/>parallel, semaphore-bounded<br/>fans out to every registered PLC")
FAN --> P1 & P2 & P3 & P4 & P5 & P6 & Pn
subgraph Fleet["WAGO PLC fleet"]
direction LR
P1("CC100")
P2("PFC100 Gen 2")
P3("PFC200 Gen 2")
P4("PFC300")
P5("Edge Controller")
P6("WP400")
Pn("TP600")
end
%% PFC400 (750-8400) omitted from this diagram - not yet in hand, see Supported hardware
subgraph FW["fwupdate - separate tool, human-run only (v2.3.0)"]
direction LR
Op("Operator<br/>(maintenance window)") --> Az("Git-gated approval<br/>(policy committed + reviewed)")
Az --> Flash("Firmware flash<br/>over WDA REST")
end
Flash --> P1 & P2 & P3 & P4 & P5 & P6 & Pn
Flash -. shares the hash-chained audit log .-> Guard
Demoed end to end with 16 PLCs of mixed device class on a single rack. The parallel fan-out model has no architectural ceiling below 100+.
Firmware updates are deliberately not an MCP tool - the server refuses
every firmware method so an agent can never flash a controller. fwupdate/
is a separate container a person runs by hand, and it refuses to start
without an approval already committed and reviewed in a git-tracked policy
file. See fwupdate/README.md for the full
authorization model.
Part 1 - For Claude Desktop / Claude Code users
Everything to get connected and start asking questions in plain English.
Demo
These are short screen recordings of the server driving real WAGO controllers from Claude Desktop, start to finish - no edited-out steps.
Overview - connecting Claude Desktop and a first interaction
Use case 1 - fleet-wide health report across all 16 PLCs
Asks the agent to reconcile a "health report" across the fleet - listing all PLCs, bulk-fetching firmware versions, and probing device types to figure out what's actually running where before trusting any conclusions.
Use case 2 - Edge Controller: building a CPU/LED health watchlist
Asks the agent to set up a watchlist monitoring CPU/service health and LED
diagnostic state on the Edge Controllers, then read it back - including the
agent pushing back to clarify ambiguous requirements before touching
anything, and discovering the actual parameter IDs via find_parameters
rather than guessing.
Use case 2 - PFC300: building a CPU/LED health watchlist
The same health-watchlist workflow as above, run against a PFC300 instead - shows the same parameter-discovery process landing on different actual parameter names for an equivalent capability.
Use case 3 - detecting and fixing NTP drift fleet-wide
Asks the agent to sync NTP time on any PLC that's drifted. The agent checks NTP status across the entire fleet first, identifies which PLCs are actually affected (stuck clocks, wrong timezone offsets), and only then invokes the time-sync method on the specific units that need it.
Use case 4 - which PLCs are reachable and what firmware are they running?
Asks the agent to sweep the entire fleet, check reachability, and report
firmware versions - all in one shot. The agent calls list_plcs, then
describe_plc in parallel across every registered controller, and returns
a clean table of what's alive, what model it is, and which firmware build it
carries.
Use case 5 - which devices still have the default NTP server configured?
Asks the agent to audit NTP configuration across the fleet and flag any controller still pointing at the factory-default time server. The kind of compliance sweep that would otherwise require manual access to each device.
Quick Start
1. Clone and configure
git clone https://github.com/WagoAlex/wago-plc-mcp-server.git
cd wago-plc-mcp-server
cp _env .env
Edit .env:
WAGO_PLC_HOSTS=192.168.1.10,192.168.1.11,192.168.1.12
DEFAULT_PLC_USERNAME=admin
PORT=6042
WAGO_TIMEOUT_SECONDS=45
WAGO_TIMEOUT_SECONDS applies to every PLC in the fleet - set it to the
slowest device class you're onboarding, not the average. CC100 needs 45+;
most classes are fine at 15. IEC 62443-4-2-hardened units (see below) expose
roughly 3x the parameters of their base class and have not been timing-tuned
yet - if one fails registration with a timeout at 45s, that's a real open
question for this project, not a typo in this guide.
2. Set PLC passwords
Every device class onboards the same way: register its IP in step 1, then give it credentials here. Two patterns, combinable:
Shared password (fleets where every PLC uses the same login):
mkdir -p secrets
echo "your-plc-password" > secrets/plc_default_password.txt
chmod 600 secrets/plc_default_password.txt
Per-PLC password (any unit with its own login - common for hardened or
customer-managed devices): add a secret named for its IP, then uncomment the
matching lines in docker-compose.yml (secrets: block and the service's
secrets: list):
echo "that-unit-password" > secrets/plc_password_192_168_2_85.txt
chmod 600 secrets/plc_password_192_168_2_85.txt
Per-PLC secrets take priority over the shared default for a matching IP, so a
mixed fleet just needs one of these per unit that doesn't share the default
login - everything else falls back to plc_default_password.txt.
3. Start
docker compose up -d
docker logs wmcp -f
On first boot the server generates an API key and announces its fingerprint (the key itself is never written to container logs). Retrieve it with:
docker exec wmcp cat /app/data/mcp_api_key
════════════════════════════════════════════════════════════════════════
NEW MCP API KEY GENERATED (fingerprint: 7290f42b…)
Stored in ./data/mcp_api_key - retrieve it with:
docker exec wmcp cat /app/data/mcp_api_key
.mcp.json:
"headers": {"Authorization": "Bearer <key>"}
Regenerate: docker exec wmcp python src/mcp_keygen.py
════════════════════════════════════════════════════════════════════════
Registration: 3/3 ready
MCP server listening on http://0.0.0.0:6042/mcp (Streamable HTTP)
4. Connect your AI client
Claude Code (one command):
claude mcp add --transport http --header "Authorization: Bearer <key>" wago-plc http://localhost:6042/mcp
Claude Desktop - add to %APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"wago-plc": {
"type": "http",
"url": "http://localhost:6042/mcp",
"headers": { "Authorization": "Bearer <your-api-key>" }
}
}
}
Fully quit and relaunch Claude Desktop. You should see a hammer icon with 29 tools:
5. Install the WAGO skill (recommended)
The bundled skill teaches the assistant the WAGO parameter names, how to operate safely, and how the tools actually behave. It's the difference between a vague answer and one that lands on the right parameter first try.
mkdir -p ~/.claude/skills
cp -r wago-plc-skill ~/.claude/skills/
What can I ask it?
You don't need to know any parameter IDs or anything about the WDA API. Just ask for what you want; the assistant works out which tools to call and deals with the REST plumbing for you.
Fleet-wide checks
| What you type | What happens |
|---|---|
| "Which PLCs are running firmware older than build 31?" | Reads firmware version from every controller in parallel and lists the ones behind |
| "Are NTP and Docker running on all Edge Controllers?" | Reads service running-flags across the fleet, highlights stopped services |
| "Show the diagnostic LED states on all PLCs" | Reads SYS, RUN, and fieldbus LED text strings from every unit |
| "Is any controller showing a fault or error state?" | Cross-checks LED strings and error parameters fleet-wide |
Single-controller diagnostics
| What you type | What happens |
|---|---|
| "What firmware version is running on 192.168.1.14?" | Reads the firmware version parameter |
| "List all network settings on Edge Controller .19" | Searches parameters by keyword, returns names + current values |
| "Is the CODESYS program loaded and running on PFC300 .22?" | Reads the CODESYS runtime state parameter |
| "What NTP server is configured on PLC .10?" | Reads NTP client configuration |
Configuration changes and remote actions
| What you type | What happens |
|---|---|
| "Set the NTP server to 192.168.0.1 on all PLCs in building A" | Writes NTP address after your confirmation; every write is recorded in the audit log |
| "Trigger an NTP time sync on the three controllers that showed clock drift" | Invokes the NTP sync action only on affected units |
| "Enable SSH on controller .14 for remote maintenance access" | Finds the SSH enable parameter and writes it after confirmation |
Ongoing monitoring
| What you type | What happens |
|---|---|
| "Set up a health monitor for the packaging line PLCs" | Creates a server-side watchlist combining LED states, service flags, and cloud status - one HTTP request per poll cycle |
| "Track the firmware update progress on all 12 PLCs" | Polls update status and progress across the fleet |
Part 2 - For automation / OT engineers
What this actually touches on your PLCs, in terms you already know, and exactly when a write or method call is allowed versus refused.
What this does and doesn't do
For automation and OT engineers
You know PLCs - TIA Portal, Studio 5000, EcoStruxure, ladder logic. Here's the 60-second translation.
What WDA/WDx is: Every WAGO controller exposes a REST API called WDA (WAGO Device Access) for system and diagnostic management - firmware version, network config, service health, status LEDs, reboot and firmware-update control. Think of it as the machine-readable equivalent of TIA Portal's Online & Diagnostics view or Studio 5000's Controller Properties - not a fieldbus, not OPC-UA, and not access to your control program's I/O data.
What MCP is: A standard protocol that lets an AI assistant call a fixed set of defined tools against a system, instead of you writing custom integration code for every request. This server turns the WDA REST API into 29 tools an AI assistant can call directly.
| Term | Plain meaning | Closest thing you already know |
|---|---|---|
| WDA / WDx | WAGO's REST API for system/diagnostic management | TIA Portal Online & Diagnostics, Studio 5000 Controller Properties |
| MCP | Protocol letting an AI assistant call a fixed set of "tools" | A structured API contract invoked by an LLM instead of your own code |
| Parameter | A single named system value (firmware version, LED state, service flag) | A diagnostic/status tag - not a control-program I/O tag |
| Method | A remote action you can trigger (NTP sync, reboot, firmware update) | An RPC / "execute" command, similar to an online action in TIA/Studio 5000 |
| Watchlist | A server-side list of parameters the PLC keeps open for cheap repeated reads | Closest analog: a Watch Table (TIA) or Trend window (Studio 5000) - polled by an agent |
What values can actually be monitored
WDA exposes the system management layer, not the real-time process image. What it does expose as live, poll-worthy values:
| Category | Example parameters | Typical use |
|---|---|---|
| Service health | 0-0-ntpclient-isrunning, 0-0-docker-isrunning, 0-0-ssh-isrunning, 0-0-openvpn-isrunning |
Detect silently stopped services |
| LED & fault state | 0-0-ledstates-1-diagnosticinformation (SYS), 0-0-ledstates-4-diagnosticinformation (RUN) |
Mirror physical status LEDs; surface diagnostic text without physical access |
| Firmware update | 0-0-firmwareupdate-status, 0-0-firmwareupdate-progress |
Track OTA update progress across a fleet |
| CODESYS runtime | 0-0-codesys3-applications |
Confirm a PLC program is loaded and running |
| Cloud connectivity | 0-0-cloudconnections-1-status-connected, 0-0-cloudconnections-1-status-filllevel |
Monitor MQTT broker reachability and queue depth |
| System time | 0-0-systemtime-now |
Verify clock synchronisation after NTP updates |
Supported hardware
| Device | Article Numbers | Notes |
|---|---|---|
| CC100 | 751-9301 · 751-9401 · 751-9402 · 751-9403 |
Slow ARM CPU - set WAGO_TIMEOUT_SECONDS=45 |
| CC100-IEC62443 | 751-9412 |
Hardened variant of CC100, same order-number prefix and class - ~1056 WDA params (additional security feature groups) vs. the base unit's 360 |
| PFC100 Gen 2 | 750-8110 · 750-8111 · 750-8112 · 750-8112/025-000 |
|
| PFC200 Gen 2 | 750-8210 · 750-8211 · 750-8212 · 750-8216 · 750-8217 |
|
| PFC300 | 750-8302 |
|
| PFC400 | 750-8400 |
Not yet in hand - order-number prefix is recognized so it registers under its own class, but nothing about it is verified against real hardware |
| Edge Controller | 752-8303/8000-0002 |
Exposes CODESYS runtime state via 0-0-plcruntime-* |
| WP400 | 762-34xx |
Web panel only - 189 WDA params, no CODESYS. HMI params: display brightness/orientation/screensaver, integrated browser startpage, touch cleaning mode |
| TP600 | 762-42xx · 762-43xx · 762-52xx · 762-53xx · 762-62xx · 762-63xx |
Full PLC+HMI - 410 WDA params. CODESYS3, BACnet, cloud, serial, all WP400 HMI params plus front LED and acoustic feedback |
Requires firmware build ≥ 28 (FW28). Tested up to 04.09.01 (FW31).
How reads and writes work
Every operation an agent can perform falls into exactly one of three classes. These are mutually exclusive and cover everything the server can do - there is no fourth kind of action.
| Class | Tools | Changes the PLC? |
|---|---|---|
| Read | list_plcs, describe_plc, find_parameters, get_parameter, get_parameters_bulk, find_methods, get_method, get_method_run, create_watchlist, read_watchlist, delete_watchlist, get_plc_audit_log |
No |
| Write a parameter | set_parameters |
Yes - changes a stored config value |
| Invoke a method | invoke_method |
Yes - triggers an action (NTP sync, reboot, firmware update, ...) |
Standard behavior (default config: live mode, no read-only hosts)
- Reads are always allowed. They have no side effects and are never gated.
- Parameter writes are allowed when the parameter is writeable. The server pre-checks writeability from its cache and refuses values the firmware marks read-only for that device/firmware, before any HTTP call reaches the PLC.
- Safe method calls are allowed. Anything that is not on the dangerous list below runs directly.
- Dangerous methods are denied. Method IDs whose segments start with
reboot,restart,factory,firmware, orformatare refused unless you explicitly allowlist the exact ID.
When a write or method call is allowed
The outcome is decided by three independent conditions. Read-only status takes precedence over everything else; otherwise the server mode decides.
| Condition | Read | set_parameters |
Safe invoke_method |
Dangerous invoke_method |
|---|---|---|---|---|
Read-only PLC (WAGO_ALLOW_WRITES set but not true, WAGO_READONLY_HOSTS, or fleet # readonly) - any mode |
Allowed | Refused | Refused | Refused |
Live mode (GITOPS_MODE=0, default) |
Allowed | Allowed if writeable | Allowed | Denied unless ID in WAGO_ALLOW_METHODS |
GitOps mode (GITOPS_MODE=1) |
Allowed | Returns a PR YAML fragment (no direct write) | Returns a PR YAML fragment | Returns a PR YAML flagged requires_human: CRITICAL; apply.py refuses to run it until a human sets approved_by |
Read it top-down: if the PLC is read-only, stop there - nothing is written. If not, the active mode determines whether a write happens directly (live) or becomes a reviewed pull request (GitOps).
Every write and every method call - allowed, refused, or denied - is recorded in the tamper-evident audit log. For the rationale behind the dangerous-method and read-only gates, see Safety gates.
Part 3 - For software / DevOps engineers
Deploying this in production, wiring up GitOps, securing the endpoint, and the full tool/config reference.
Production deployment
Deployment options
| Path | Best for | Requires |
|---|---|---|
| Docker | Plant server, shared multi-user fleet | Docker host on the OT network |
| Portainer | Docker host managed remotely through Portainer's UI | Portainer pointed at that Docker host |
| Claude Desktop extension | One engineer, a handful of PLCs, no Docker | Claude Desktop |
| MCP Registry | Clients that install servers from the official registry | uv or Docker |
| Windows .exe | OT engineer laptop, air-gapped Windows | Nothing - zero dependencies |
| uvx / PyPI | Developer machine, any OS | uv installed |
| IDE | Cursor, VS Code + Copilot | uv installed |
| HTTP remote | ChatGPT, OpenAI API, n8n | Running server reachable over network |
Config file examples for every path: deploy/configs/
Docker (recommended)
One server, many clients. PLCs register once at startup and stay connected.
cp _env .env # edit PLC IPs, password, API key
docker compose up -d
Connect any client to http://<host>:6042/mcp with Authorization: Bearer <key>.
Large fleet - host file:
# data/fleet.txt
# Production floor A
192.168.1.10
192.168.1.11
# Production floor B
192.168.2.10
# 192.168.2.11 decommissioned
WAGO_PLC_HOSTS_FILE=/app/data/fleet.txt
Fleet changes require editing the file and restarting the container. The
audit log persists across restarts on the ./data volume.
Portainer
Same image, deployed and managed from Portainer's UI instead of the CLI -
useful when Portainer is remote from the Docker host and can't see its
filesystem (no env_file, no Docker Secrets - see the comment block in the
compose file for why).
- Edit the volume path first.
docker-compose.portainer.ymlbind-mounts./dataas an absolute host path (/home/wago/Documents/mcp/wago-plc-mcp-server/data) because Portainer resolves relative paths against its own stack folder, not your checkout. Change that path to match wherever this repo lives on the target Docker host before deploying - otherwise the audit log and API key land in the wrong place. - Portainer → Stacks → Add stack, paste the (edited) compose file.
- Environment variables panel: fill in the values listed in the
compose file's header comment, or upload
portainer.env.examplevia Load variables from a .env file and edit from there.MCP_API_KEYhere replaces the Docker Secret (secrets/mcp_api_key.txt) thatdocker-compose.ymluses - it lives in Portainer's database instead of a file, which is a real trade-off against the CRA-hardening secrets setup, not a drop-in equivalent. - Deploy the stack. Same
http://<host>:6042/mcpendpoint as the CLI path.
Windows .exe
Self-contained bundle - no Python, no package manager.
deploy\windows\build.bat # build once on any Windows machine with Python 3.11+
deploy\windows\setup.bat # configure .env and get the Claude Desktop JSON snippet
%APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"wago-plc": {
"command": "C:\\wago-mcp\\wago-proxy.exe",
"env": {
"WAGO_MCP_URL": "http://localhost:6042/mcp",
"WAGO_MCP_API_KEY": "your-api-key"
}
}
}
}
Claude Desktop extension (.mcpb)
One file, no Docker and no Python install: Claude Desktop runs the server itself. Meant for one engineer and a handful of controllers; use Docker for a shared or production fleet.
- Download
wago-plc-mcp-server-<version>.mcpbfrom the latest release. - Open it with Claude Desktop: double-click the file, or drag it onto the Claude Desktop window.
- Fill in the install form: PLC IP addresses, username, password and request timeout. Claude Desktop keeps the password in its secure storage.
- Leave Allow writes and method calls unticked unless Claude should change these controllers. Unticked, every PLC is read-only: parameter writes, method calls and file uploads are refused and logged.
Writes and refusals are recorded in ~/.wago-plc-mcp/audit.log. Reboot,
factory reset and firmware methods stay blocked even with writes allowed.
MCP Registry
Listed in the official MCP Registry
as io.github.WagoAlex/wago-plc-mcp-server. Clients that install servers from
the registry offer two packages. Both ask for the PLC IPs and password, and for
WAGO_ALLOW_WRITES, which defaults to false (every PLC read-only).
| Package | Runs as | Needs |
|---|---|---|
PyPI wago-plc-mcp-server |
uvx wago-plc-mcp-server over stdio |
uv |
Docker wagoalex/wago-plc-mcp-server |
docker run -i --network host ... over stdio |
Docker on a machine that reaches the PLCs |
uvx / PyPI
Runs the full server locally in stdio mode - no Docker, no proxy, no persistent process. Starts fresh each Claude session (PLCs re-register on connect, adds a few seconds).
Requires: uv
%APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"wago-plc": {
"command": "uvx",
"args": ["wago-plc-mcp-server"],
"env": {
"TRANSPORT": "stdio",
"WAGO_PLC_HOSTS": "192.168.1.10,192.168.1.11",
"DEFAULT_PLC_USERNAME": "admin",
"DEFAULT_PLC_PASSWORD": "wago",
"WAGO_TIMEOUT_SECONDS": "45",
"LOG_LEVEL": "WARNING"
}
}
}
}
Prefer Docker for fleets > 20 PLCs to avoid per-session re-registration.
To keep every PLC read-only, add "WAGO_ALLOW_WRITES": "false" to env
(see Safety gates).
IDE (Cursor, VS Code)
Cursor - .cursor/mcp.json in the project root:
{
"servers": {
"wago-plc": {
"command": "uvx",
"args": ["wago-plc-mcp-server"],
"env": {
"TRANSPORT": "stdio",
"WAGO_PLC_HOSTS": "192.168.1.10",
"DEFAULT_PLC_USERNAME": "admin",
"DEFAULT_PLC_PASSWORD": "wago"
}
}
}
}
VS Code + Copilot - .vscode/mcp.json, same structure.
HTTP remote (ChatGPT API, n8n, OpenAI)
Any client that supports MCP over HTTP connects to http://<host>:6042/mcp
with Authorization: Bearer <key>. For legacy SSE clients set
TRANSPORT=sse in .env and point at /sse.
OpenAI Responses API:
response = client.responses.create(
model="gpt-4o",
tools=[{
"type": "mcp",
"server_url": "http://plc-gateway.plant.internal:6042/mcp",
"server_label": "wago-plc",
"headers": {"Authorization": "Bearer <your-api-key>"}
}],
input="List all PLCs and their firmware versions."
)
Skills - install the right one
Three skills ship with this repo - install the one that matches your use case:
| Skill | For | Install |
|---|---|---|
wago-plc-skill/SKILL.md |
Claude Desktop / Claude Code end users - plain-English interaction, safety guidance, troubleshooting | cp -r wago-plc-skill ~/.claude/skills/ |
wago-plc-agent-skill/SKILL.md |
Autonomous agents / pipelines - tool I/O contracts, error shapes, retry rules, watchlist lifecycle | cp -r wago-plc-agent-skill ~/.claude/skills/ |
wago-quickref/SKILL.md |
Contributors to this repo - raw WDA HTTP behaviour, pagination encoding, payload shapes | cp -r wago-quickref ~/.claude/skills/wago-plc-mcp-server |
GitOps write-gate
For production environments where every PLC configuration change needs a human-reviewed audit trail before it reaches hardware - an ArgoCD-style pattern applied to PLCs.
How it works
Agent proposes a config change
│
▼ (GITOPS_MODE=1)
set_parameters / invoke_method returns YAML instead of writing
│
▼
Agent commits YAML to wago-plc-config repo and opens a PR
│
▼
Engineer reviews and approves the PR
│
▼
CI runs: python scripts/apply.py plcs/192.168.1.10.yaml --execute
│
▼
Live PLC updated - ops files self-delete on success
The CI step is a GitHub Actions workflow living in the config repo, not here -
it decides dry-run vs. execute purely from the triggering event, never from a
flag you set: a pull request always dry-runs (prints drift, touches nothing),
and only a push to main (i.e. a merge) executes. It runs on a self-hosted
runner because GitHub-hosted runners have no route to the PLC subnet, and it
borrows scripts/apply.py from this repo via sparse-checkout on every run - a
fix here is picked up there without a version bump. Full mechanics (trigger
table, checkout steps, secrets, why contents: write is needed for the
ops-file-cleanup commit): wago-plc-config README - How the GitHub Actions
workflow works.
Enable
GITOPS_MODE=1 # intercept writes; return YAML fragments for PR
GITOPS_MODE=0 # default: write directly (still fully audit-logged)
# Only needed if your config repo isn't named/owned wago-plc-config -
# every returned YAML fragment's next_step points the agent at this repo.
WAGO_GITOPS_REPO=wago-plc-config
Config YAML - two file types
plcs/<ip>.yaml - desired steady state
plc_ip: 192.168.1.10
managed_parameters:
0-0-ntpclient-enabled: true
0-0-ntpclient-configuredtimeservers:
- 192.168.1.1
0-0-snmp-enable: true
0-0-snmp-communities-1-name: ops-team
0-0-snmp-location: Building-A-Panel-3
apply.py reads the live PLC, diffs it against this file, and patches only
parameters that have drifted. Idempotent - safe to run in CI on every merge.
ops/<id>.yaml - one-shot action (self-deletes on success)
id: b7d3e1f9
proposed_at: 2026-06-21T10:00:00+00:00
proposed_by: agent-claude-code
plc_ip: 192.168.1.10
action: invoke_method
method_id: 0-0-ntpclient-updatetime
arguments: {}
Apply manually
# Show what would change - no writes
python scripts/apply.py plcs/192.168.1.10.yaml
# Apply drift to live PLC
python scripts/apply.py plcs/192.168.1.10.yaml --execute
# Invoke a one-shot method
python scripts/apply.py ops/b7d3e1f9.yaml --execute
Supported subsystems (parameter IDs)
| Subsystem | Key parameters | Helper |
|---|---|---|
| Cloud / MQTT | 0-0-cloudconnections-1-* |
gitops.cloud_params() |
| NTP | 0-0-ntpclient-enabled/configuredtimeservers/updateinterval |
gitops.ntp_params() |
| SNMP | 0-0-snmp-enable/communities-1-name/location/contact |
gitops.snmp_params() |
| Serial port | 0-0-serialinterfaces-1-assignedmode/assignedowner |
gitops.serial_params() |
| OpenVPN | 0-0-openvpn-enabled/configurationdescription |
gitops.openvpn_params() |
| HMI browser | 0-0-integratedwebbrowser-startpage/startpagefavorite |
gitops.browser_params() |
| FTP / FTPS | 0-0-ftpd-enabled/ftps |
direct |
| SSH | 0-0-ssh-enabled |
direct |
| Docker | 0-0-docker-enabled |
direct |
| CODESYS 3 webserver | 0-0-codesys3-webserver-enabled |
direct |
Full parameter ID reference with YAML examples for every subsystem:
docs/gitops/README.md
The config repo that receives these YAML fragments and runs apply.py via CI:
github.com/WagoAlex/wago-plc-config
Safety gates - guarding against a rogue agent
The risk with giving an AI agent write access to industrial controllers is not just "it might delete something" - it's that an agent can go off-script (hallucination, prompt injection, a bug) and take a high-consequence action you never wanted. On a production line, a config change with side effects or a badly-timed reboot can mean equipment damage or worse. These gates are enforced in code and cannot be overridden by the agent:
| Gate | What it does | Configure |
|---|---|---|
| Read-only PLCs | Listed controllers reject all writes, method calls and file uploads, in every mode | WAGO_READONLY_HOSTS=ip,ip or a # readonly tag per line in the fleet file |
| Fleet-wide write switch | Freezes every PLC the same way. Unset keeps writes possible; any value other than true blocks them, so a missing or mistyped value fails closed. The Claude Desktop extension sets it from its "Allow writes" checkbox, off by default |
WAGO_ALLOW_WRITES=true to allow, e.g. false to freeze all |
| Dangerous-method denylist | Reboot / restart / factory-reset / firmware / format are denied in live mode unless explicitly allowlisted | WAGO_ALLOW_METHODS=<exact-method-id> to re-enable one |
| Human approval for dangerous ops | In GitOps mode these become a PR flagged requires_human: CRITICAL; apply.py refuses to run until a human sets approved_by |
set approved_by during PR review, or inject WAGO_APPROVED_BY from CI |
The intended path for any high-consequence action is therefore a human-reviewed
PR plus an audit-log entry - not an autonomous tool call. A denial is the
system working as designed. Full details and a dry-run walkthrough:
docs/gitops/README.md → Safety model
Step-by-step guide for reviewers (GitHub UI and CLI): github.com/WagoAlex/wago-plc-config
Firmware updates
Firmware is the one change on a controller that a follow-up commit cannot undo. This project therefore splits it across three places, each owning one question:
| Question | Where it is answered |
|---|---|
| Can the agent do it? | Here - no, and the section below explains why |
| Who approves it, who counts as an approver, and how do I require two people? | wago-plc-config README |
| How do I run it, how does the tool resolve the approver, what if it fails? | fwupdate/README.md |
| What does the audit chain contain and how do I verify it? | Here, below |
| What do the REST calls actually do? | docs/wda-firmware-update.md |
| I know GitHub as a repository but not CI/CD - walk me through it | docs/plc-change-control.html |
The agent cannot flash a controller
invoke_method refuses every firmware* method in live mode, on every device,
and records the refusal in the audit log. This is not configuration you are
expected to relax:
> Update the firmware on 192.168.42.121
Method '0-0-firmwareupdate-activate' is denied by safety policy
(dangerous; not in WAGO_ALLOW_METHODS).
Firmware updates are performed by a separate tool in this repository,
fwupdate/, which a person runs during a maintenance
window. It is not reachable by the agent, and it refuses to start unless an
approval for that exact device and revision is committed to your config
repository. The same "a human commits, the machine applies" shape as the
GitOps write-gate above - with the human step made mandatory rather than
optional.
It shares this server's audit chain
A firmware run is not a side channel. Authorization, every refusal, success,
device-reported failure, timeout, and an abort mid-flash are appended to the
same tamper-evident hash chain as set_parameters and invoke_method, by
the same src/audit.py, on the same volume.
Each record names who approved the change and how that was established - the pull request and its reviewers where there was one, and whether it was self-approved. Nobody types their own name into a file:
docker exec wmcp python /app/src/audit_verify.py --log /app/data/audit.log
# [PASS] Chain intact - 13 entries verified
git show 18d2cbf # who approved it, and when
Security
API key management
The server resolves the MCP API key in priority order:
- Docker Secret
/run/secrets/mcp_api_key- recommended for production - Env var
MCP_API_KEY - Persisted file
./data/mcp_api_key- auto-generated on first boot, survives container recreations - Auto-generate - new key if none of the above exist
# Regenerate (only affects the auto-generated/persisted key - has no effect
# if a Docker Secret or MCP_API_KEY env var is set, since those outrank it)
docker exec wmcp python src/mcp_keygen.py
docker restart wmcp
TLS configuration
Both TLS legs are opt-in. The server starts without TLS and logs a startup warning for each disabled leg.
WDA connections (server → PLC) - three options:
# Option A: Per-PLC cert pinning (recommended for self-signed certs)
openssl s_client -connect 192.168.1.10:443 </dev/null 2>/dev/null \
| openssl x509 > secrets/plc_cert_192_168_1_10
# Declare the secret in docker-compose.yml and restart
# Option B: Private CA bundle
WAGO_TLS_CA=/run/secrets/wago_ca.pem
# Option C: System trust store (only if PLC certs are CA-signed)
WAGO_TLS_CA=true
MCP endpoint (client → server):
openssl req -x509 -newkey rsa:4096 \
-keyout secrets/mcp_tls_key.pem \
-out secrets/mcp_tls_cert.pem \
-days 365 -nodes -subj "/CN=wago-mcp"
MCP_TLS_CERT=/run/secrets/mcp_tls_cert
MCP_TLS_KEY=/run/secrets/mcp_tls_key
Enforcing both legs are actually configured:
SECURITY_PROFILE=hardened
Opt-in, default is unset. When set, the server refuses to start
(SystemExit(1), before contacting any PLC) unless WAGO_TLS_CA is a real
CA/cert path (not unset/false/0) and both MCP_TLS_CERT/MCP_TLS_KEY
are set - i.e. it turns the two startup warnings above into a hard stop. It
only checks that TLS is configured, not that the cert is otherwise trustworthy
- a self-signed cert you point
MCP_TLS_CERTat still starts hardened.
Audit log
Every set_parameters and invoke_method call is appended to a
tamper-evident hash-chained JSON-lines log:
Entry 1 {"ts":"…","action":"set_parameters",…,"prev":"0000…0000"} ← genesis
Entry 2 {"ts":"…","action":"invoke_method",…,"prev":"a3f1…c2d8"}
Entry 3 {"ts":"…","action":"set_parameters",…,"prev":"7b2e…91fa"}
Each entry includes timestamp, PLC IP, parameter IDs + values, and
key-<first 8 chars of API key> for per-engineer traceability.
# Tail live log
docker exec wmcp tail -f /app/audit.log
# Verify chain integrity
docker exec wmcp python src/audit_verify.py
# → [PASS] Chain intact - 42 entries verified (/app/audit.log)
Security feature summary
| Feature | Status |
|---|---|
Bearer auth on /mcp |
✅ Auto-generated key; Docker Secret + env override; /health exempt |
| Rate limiting | ✅ 60 req/60 s per source IP; 429 with Retry-After |
| Auth failure alerts | ✅ WARNING per failure; ERROR at 10 consecutive from same IP |
| WDA Bearer token auth | ✅ Credentials sent once; cached token refreshed on 401 |
| Hash-chained audit log | ✅ Tamper-evident JSON-lines on ./data volume |
| Default password warning | ✅ Startup WARNING if factory default password detected |
| TLS - WDA connections | ⚙️ Off by default; enable with WAGO_TLS_CA or per-PLC Docker Secret |
| TLS - MCP endpoint | ⚙️ Off by default; enable with MCP_TLS_CERT + MCP_TLS_KEY |
| CycloneDX SBOM | ✅ Published alongside every release image |
| Docker Secrets | ✅ PLC passwords, MCP key, TLS certs all mountable as secrets |
| CVE scanning | ✅ Weekly grype scan on SBOM; HIGH/CRITICAL fails CI |
For the vulnerability disclosure policy, patch SLA, and support lifetime see SECURITY.md.
Tool reference
Discovery
| Tool | Description |
|---|---|
list_plcs |
List all registered PLC IPs |
describe_plc(plc_ip) |
Capability counts + feature names + device_class, expected_parameter_count, parameter_count_ok |
get_plc_audit_log(plc_ip, action, limit) |
Read recent tamper-evident audit log entries; filter by PLC and/or action, newest first (max 500) |
get_device(plc_ip, device_id) |
Device resource plus the features it exposes |
get_feature(plc_ip, feature_id) |
Feature plus nested features, contained parameter/method definitions |
get_enum_definition(plc_ip, enum_id) |
An enum's full case list (value → stringValue) |
get_parameter_definition(plc_ip, parameter_id) |
writeable/userSetting/dataType/enum link, without reading a value |
Parameters
| Tool | Description |
|---|---|
find_parameters(plc_ip, query, writeable_only, user_settings_only, limit) |
Search by keyword (default 20 results, max 255) |
get_parameter(plc_ip, parameter_id) |
Read one value, enum labels resolved |
get_parameters_bulk(requests) |
Read one param from N PLCs in parallel |
set_parameters(plc_ip, parameters) |
Write one or more parameters (bulk PATCH) |
set_parameter(plc_ip, parameter_id, value) |
Write a single parameter |
get_parameter_referenced_instances(plc_ip, parameter_id) |
Instances referencing an instance_identity_ref parameter |
list_parameter_instances(plc_ip, parameter_id) |
Instance numbers of a class-typed parameter |
get_parameter_instance(plc_ip, parameter_id, instance_no) |
One instance: its device, own parameters, and methods |
Methods
| Tool | Description |
|---|---|
find_methods(plc_ip, query, limit) |
Search by keyword |
get_method(plc_ip, method_id) |
Fetch inArgs/outArgs schema |
invoke_method(plc_ip, method_id, arguments, wait) |
Execute sync or async |
get_method_run(plc_ip, method_id, run_id) |
Poll async run status |
list_method_runs(plc_ip, method_id) |
Past runs still held server-side |
delete_method_run(plc_ip, method_id, run_id) |
Free a server-side run result early |
Watchlists
| Tool | Description |
|---|---|
list_watchlists(plc_ip) |
Watchlist IDs still active server-side |
create_watchlist(plc_ip, parameter_ids, timeout_seconds) |
Register a server-side monitoring list on the PLC |
read_watchlist(plc_ip, watchlist_id) |
Return current values for all watched parameters (one HTTP request) |
delete_watchlist(plc_ip, watchlist_id) |
Release the watchlist immediately |
Files (file_id-typed parameters)
| Tool | Description |
|---|---|
create_file(plc_ip, context_parameter_id) |
Allocate a file_id for upload |
upload_file(plc_ip, file_id, content_base64, content_type) |
Upload whole file content (base64) |
download_file(plc_ip, file_id) |
Download file content as base64 |
get_file_metadata(plc_ip, file_id) |
Size/type without downloading the body |
Class-instance and file tools are implemented against the WDA spec but have not been exercised against real hardware - no
instantiations- orfile_id-typed parameter has shown up on any device in our test fleet yet. Seedocs/functional-test-status.md.
Why watchlists exist: Every get_parameter call opens a new HTTPS connection. For repeated polling of a fixed set across a fleet, the overhead compounds: 10 parameters × 15 PLCs every 30 seconds = 150 HTTPS round-trips per cycle. Watchlists solve this - one read_watchlist returns all current values in a single request.
Example workflows
Read firmware version from all PLCs in one call:
get_parameters_bulk([
{"plc_ip": "192.168.1.10", "parameter_id": "0-0-version-firmwareversion"},
{"plc_ip": "192.168.1.11", "parameter_id": "0-0-version-firmwareversion"}
])
Sync NTP time on a PLC:
find_methods("192.168.1.10", "ntp")
→ ["0-0-ntpclient-updatetime"]
invoke_method("192.168.1.10", "0-0-ntpclient-updatetime", wait=True)
→ {"status": "done", "run_id": "1", "out_args": {}}
Poll operational health with a watchlist:
create_watchlist("192.168.1.10", [
"0-0-ledstates-1-diagnosticinformation",
"0-0-ledstates-4-diagnosticinformation",
"0-0-ntpclient-isrunning",
"0-0-docker-isrunning",
"0-0-cloudconnections-1-status-connected"
], timeout_seconds=300)
read_watchlist("192.168.1.10", "1") # call every 30 s
delete_watchlist("192.168.1.10", "1") # explicit cleanup when done
Configuration reference
| Variable | Default | Description |
|---|---|---|
WAGO_PLC_HOSTS |
- | Comma-separated PLC IPs |
WAGO_PLC_HOSTS_FILE |
- | Path to host file (one IP per line) |
DEFAULT_PLC_USERNAME |
admin |
Shared username |
DEFAULT_PLC_PASSWORD |
wago |
Shared password (use Docker Secret instead) |
PLC_PASSWORDS_<ip_underscores> |
- | Per-PLC password override |
MCP_API_KEY |
- | Bearer token for /mcp; auto-generated if absent |
GITOPS_MODE |
0 |
1 = intercept writes, return YAML fragments |
WAGO_GITOPS_REPO |
wago-plc-config |
Config repo name/path shown in the returned YAML's next_step - point this at a fork or a differently-named repo |
WAGO_READONLY_HOSTS |
- | Comma-separated PLC IPs that refuse set_parameters/invoke_method/file uploads in every mode |
WAGO_ALLOW_WRITES |
- (writes allowed) | Fleet-wide switch: true allows writes; any other value (false, empty) makes all PLCs read-only |
WAGO_ALLOW_METHODS |
- | Comma-separated exact method IDs to re-allow from the dangerous-method denylist in live mode |
WAGO_TLS_CA |
- | WDA TLS: false (off), true (system CA), or path |
MCP_TLS_CERT |
- | Path to TLS cert for MCP endpoint |
MCP_TLS_KEY |
- | Path to TLS private key for MCP endpoint |
MCP_TLS_KEY_PASSWORD |
- | Password for encrypted TLS private key (optional) |
SECURITY_PROFILE |
- | hardened refuses to start unless WAGO_TLS_CA + MCP_TLS_CERT/MCP_TLS_KEY are all set - turns the TLS-disabled warnings into a startup failure |
AUDIT_LOG_FILE |
/app/audit.log |
Audit log path inside container |
SYSLOG_HOST |
- | Syslog/SIEM receiver hostname; enables audit forwarding |
SYSLOG_PORT |
514 |
Syslog receiver port |
SYSLOG_TCP |
false |
true = TCP (reliable), false = UDP |
TRANSPORT |
streamable-http |
streamable-http or sse |
HOST |
0.0.0.0 |
Bind address |
PORT |
6042 |
Listen port |
WAGO_TIMEOUT_SECONDS |
45 |
Per-PLC HTTP timeout (CC100 needs 45+) |
WAGO_PAGE_LIMIT |
500 |
WDA pagination page size |
WAGO_MAX_CONCURRENT_REGISTRATIONS |
5 |
Parallel PLC init limit |
WAGO_MAX_CONCURRENT_READS |
10 |
Parallel PLC request limit inside get_parameters_bulk |
LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING / ERROR |
LOG_FILE |
/app/mcp_server.log |
Debug log path inside container |
Reference
Cross-cutting material that isn't specific to any one persona - come back to these as needed.
Frequently asked questions
Can the AI modify my control program or process I/O values?
No. The WDA REST API has no access to the CODESYS runtime, PLC variables, fieldbus I/O, or anything in your control program. Field I/O still goes through OPC-UA, Modbus TCP, or WAGO I/O-Check.
What if the AI writes a wrong value?
Every write is recorded in the tamper-evident audit log with timestamp, parameter ID, value written, and which API key made the change. For most WDA parameters a wrong value is correctable by writing the correct value again. For disruptive actions like firmware update or reboot, the assistant asks for explicit confirmation before executing.
Does the server need internet access after initial setup?
No. All traffic is local: AI client → MCP server (port 6042) → PLCs (port 443 HTTPS). No cloud calls, no telemetry. Suitable for air-gapped OT networks once the Docker image has been transferred to the host.
Our PLCs have different passwords. How do we configure that?
DEFAULT_PLC_PASSWORD=wago # applied to all PLCs unless overridden
PLC_PASSWORDS_192_168_1_11=secret # override for this unit (IP with underscores)
What firewall rules does IT need to open?
| Direction | Source | Destination | Port | Protocol |
|---|---|---|---|---|
| Inbound | Engineer workstations | MCP server host | 6042 | TCP |
| Outbound | MCP server host | WAGO PLC IPs | 443 | TCP (HTTPS) |
Can multiple engineers share one server?
Yes. Deploy one container on a host reachable from the OT network. Each
engineer connects their own client to http://<server>:6042/mcp. Use a
shared API key, or provision individual keys per engineer for per-person
traceability in the audit log.
Which firmware version is required?
Firmware build ≥ 28 (04.xx.xx(28) or later). Check the build number in
the controller's web interface under Device Information, or ask the
assistant: "What firmware version is PLC 192.168.x.x running?"
Fetching raw parameter data (curl)
For bulk exports, debugging, or building contract-test cassettes, bypass the
MCP layer and query WDA directly. WDA hard-caps at 255 entries per page -
most device classes need two pages. Always include
parameter-errors-as-data-attributes=true.
IP=192.168.1.10
OUT=wda-parameters-${IP}.json
{
curl -sk -u "admin:wago" -H "Accept: application/vnd.api+json" --max-time 90 \
-G --data-urlencode "parameter-errors-as-data-attributes=true" \
--data-urlencode "page[limit]=255" \
--data-urlencode "page[offset]=0" \
"https://${IP}/wda/parameters"
curl -sk -u "admin:wago" -H "Accept: application/vnd.api+json" --max-time 90 \
-G --data-urlencode "parameter-errors-as-data-attributes=true" \
--data-urlencode "page[limit]=255" \
--data-urlencode "page[offset]=255" \
"https://${IP}/wda/parameters"
} | jq -s '{data: (map(.data) | add)}' > "$OUT"
echo "Saved $(jq '.data | length' "$OUT") parameters to $OUT"
page[limit] and page[offset] must be passed via --data-urlencode - embedding literal brackets in the URL string is silently ignored and causes an infinite page-0 loop.
Requirements
- Docker 24+ with Compose v2
- WAGO PLC with WDx/WDA REST API enabled (firmware build ≥ 28)
- Network route from Docker host to PLC subnets
Claude Desktop connects directly over HTTP (no proxy needed) unless you're
using the Windows .exe bundle or running wago_proxy.py
directly for an older client - that path additionally needs Python 3.11+
and fastmcp on the client machine.
Security & CRA compliance
This project targets compliance with the EU Cyber Resilience Act (Regulation 2024/2847).
| Document | Purpose |
|---|---|
| SECURITY.md | Vulnerability reporting, patch SLA, support lifetime |
| docs/threat-model.md | STRIDE risk assessment |
| docs/cra-compliance-matrix.md | Annex I requirements → evidence mapping |
| docs/eu-declaration-of-conformity.md | CRA Article 28 self-declaration |
| docs/technical-file.md | CRA Article 31 technical file index |
Releases
Pre-built images on Docker Hub. A CycloneDX SBOM is published alongside every release. docker compose up -d pulls the latest automatically.
License
Metadata
Release files for wago-plc-mcp-server 2.3.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| wago_plc_mcp_server-2.3.3.tar.gz | 99.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| wago_plc_mcp_server-2.3.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 164.0 kB
Release files / wago_plc_mcp_server-2.3.3.tar.gz
| Download URL | wago_plc_mcp_server-2.3.3.tar.gz |
|---|---|
| Size | 99.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1de36c9b991852fdb1e8d9bb49e1440bbabef50de8176f8969793c69b7a69183
|
|
BLAKE2b-256 checksum How to use checksums |
87023456d6d4e6e041e78896964ba1cb6d3a4dfd456749b5a36c2d00ca177894
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / wago_plc_mcp_server-2.3.3-py3-none-any.whl
| Download URL | wago_plc_mcp_server-2.3.3-py3-none-any.whl |
|---|---|
| Size | 64.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8e488933c8e1875e199df0c7a689e0f70401e7891ddb3fb9018945fa776995ae
|
|
BLAKE2b-256 checksum How to use checksums |
3588fa33ff68177275fa56118e64972d5aef6011cc1692e1012ebaaa6388453e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|