mcp server
EtherNetIP-MCP
MCP server that connects AI agents (Claude, Cursor, any MCP client) to Allen-Bradley ControlLogix/CompactLogix PLCs over EtherNet/IP: read and write tags, browse the tag list, test on a mock PLC.
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 22, 20260.3.0 package and tool migration: Install
industriagents-ethernetip-mcpfrom npm (requires uv) or PyPI; the command remainsethernetip-mcp. The advertised catalog now contains 8 tools. Older tool names in the examples below remain compatibility calls. See the architecture and canonical tool guide and release guide.
EtherNet/IP MCP Server
MCP-based tooling for Rockwell/Allen-Bradley Logix controllers (ControlLogix, CompactLogix, Micro800) over EtherNet/IP, built on pycomm3's LogixDriver. It follows the same layout as the other IndustriConnect protocol servers: a Python MCP server plus a mock PLC, so AI agents can be exercised against simulated controller tags before they go anywhere near real equipment.
The MCP server exposes consistent tool names and the canonical { success, data, error, meta } response envelope, while the mock PLC provides an in-memory tag database for offline testing. The server is read-only by default: write tools are refused until you set ENIP_WRITES_ENABLED=true.
Status: early. CI runs
ruff, unit tests and end-to-end tests: the tests start the mock PLC and drive the realethernetip-mcpprocess over stdio with the MCP client SDK. Thepycomm3path to real controllers is unit-tested against a fake driver that enforcespycomm3's real method signatures, and its port, timeout and retry handling are tested with the realLogixDriveragainst a local socket. It has not been run against a physical controller from this repository yet. Validate on a bench controller before relying on it; see Known limitations.
Repository Layout
EtherNetIP-MCP/ # included in IndustriConnect as the EtherNetIP-Project/ submodule
├── README.md
├── LICENSE
├── CONTRIBUTING.md · SECURITY.md · CODE_OF_CONDUCT.md
├── .github/ # CI, Dependabot, issue and PR templates
├── ethernetip-python/ # Python MCP server (uv), tests in ethernetip-python/tests
└── ethernetip-mock-server/ # EtherNet/IP mock PLC (uv, JSON-over-TCP)
Components
- ethernetip-python: Python 3.11+ server built with
pycomm3,mcp[cli](1.x) anduv. Ships theethernetip-mcpentry point. - ethernetip-mock-server: Python mock PLC with a small in-memory tag database (two controller-scoped tags and five
Program:MainProgramtags: scalars, an array and a string). It speaks a simple JSON-over-TCP protocol rather than CIP, which the server reaches through its JSON bridge (ENIP_JSON_BRIDGE=true).
Install
You need Python 3.11+ and uv.
git clone https://github.com/IndustriAgents/EtherNetIP-MCP.git
cd EtherNetIP-MCP
Quick Start (against the mock)
Start the mock PLC (listens on 127.0.0.1:5025):
cd ethernetip-mock-server
uv sync
uv run ethernetip-mock-server
Install the server once, so the MCP client does not have to create its virtual environment (and possibly download Python) inside its startup timeout:
cd ethernetip-python
uv sync
Then register the server with your MCP client. It speaks MCP over stdio; this config points it at the mock through the JSON bridge:
{
"mcpServers": {
"ethernetip": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/EtherNetIP-MCP/ethernetip-python", "run", "ethernetip-mcp"],
"env": {
"ENIP_HOST": "127.0.0.1",
"ENIP_PORT": "5025",
"ENIP_JSON_BRIDGE": "true"
}
}
}
}
GUI clients such as Claude Desktop often start without ~/.local/bin on their PATH. If the client cannot find uv, put the absolute path printed by which uv in command.
This config is read-only: the write tools answer success: false until you add "ENIP_WRITES_ENABLED": "true". To try it without a client, use the MCP Inspector; see CONTRIBUTING.md.
For a real controller, drop ENIP_JSON_BRIDGE and ENIP_PORT, and set ENIP_HOST to the controller (or the Ethernet module in its chassis) and ENIP_SLOT to the processor slot; for a Micro800 set ENIP_MICRO800=true instead of a slot. Read SECURITY.md and Known limitations first.
Configuration
Set these in the client config's env or the process environment. The server reads them when it starts; an invalid value (a port that is not a number, ENIP_WRITES_ENABLED=ture, conflicting settings) stops the server at startup with a configuration error message on stderr and exit code 2. An empty value counts as unset.
Where settings come from. The server reads the process environment (the MCP client's env) and, optionally, two kinds of file:
ethernetip-mcp --env-file PATHreads the file you name, and honours every setting in it, safety switches included.- Without
--env-file, the server loads one.envby itself, and onlyethernetip-python/.envof a source checkout. It looks in that project directory, found from the package's own location, and never in the working directory or a parent directory; an installed package (site-packages) has no implicit.envat all. The working directory is usually the MCP client's project, which this server should not trust, and settings such as the host or the tag map decide where a write goes. Even that file may only set this server's own settings (ENIP_*andTAG_MAP_FILE) and may not enable a safety switch:ENIP_WRITES_ENABLED,ENIP_SYSTEM_CMDS_ENABLEDand any other variable in it (PATH,PYTHONPATH,LD_PRELOAD, proxy settings, …) are ignored with a warning on stderr naming the variable. Names are compared case-insensitively, as Windows does, and anexportprefix makes no difference.
Variables already set in the environment always win over either file. ethernetip-python/.env.example lists the settings.
| Variable | Default | Meaning |
|---|---|---|
ENIP_HOST | 127.0.0.1 | Controller IPv4 address or host name, or the mock's host. May include a port (10.0.0.5:44819). The JSON bridge also accepts IPv6 (::1, [::1]:5025); the CIP path is IPv4 only, as pycomm3 is. |
ENIP_PORT | 44818 | TCP port, for both backends: set it to the mock's port (5025) for the JSON bridge. A port written into ENIP_HOST or ENIP_PATH also works; if both are given they must agree. |
ENIP_SLOT | 0 | Processor slot in the chassis (0–255). Not used with ENIP_PATH or ENIP_MICRO800. |
ENIP_PATH | unset | Full CIP route (e.g. 10.0.0.5/backplane/2); overrides ENIP_HOST and ENIP_SLOT. Not allowed with the JSON bridge. |
ENIP_MICRO800 | false | The target is a Micro800: connect to the IP alone (a non-zero ENIP_SLOT is a configuration error), and refuse the connection if the controller does not identify as a Micro800 (catalog 2080-*). pycomm3 detects a Micro800 on its own either way; get_connection_status shows what it detected. |
ENIP_TIMEOUT | 5 | Seconds. On the CIP path, the socket timeout for connecting and for each reply (pycomm3's own default is also 5 s). On the JSON bridge, the limit for each request. |
ENIP_JSON_BRIDGE | false | Talk to the mock's JSON-over-TCP protocol instead of CIP. |
ENIP_MAX_RETRIES | 3 | Retries (0–10) for connection-level failures: a call makes at most this + 1 attempts. Reads may be re-sent. A write or set_plc_time is retried only while the connection is being opened, never once its request may have been sent (see Writes are sent at most once). Errors the device reports (unknown tag, wrong type) are not retried. |
ENIP_RETRY_BACKOFF_BASE | 0.5 | Base delay of the retry backoff, in seconds (0–60): retry n waits this × 2n−1, capped at 30 s per wait. |
ENIP_WRITES_ENABLED | false | Allows the write tools, and is one of the two settings set_plc_time needs. Off by default: a model will call a tool it has been given. Ignored in an automatically found .env. |
ENIP_SYSTEM_CMDS_ENABLED | false | Allows system commands. set_plc_time changes the controller, so it needs both this and ENIP_WRITES_ENABLED=true. Ignored in an automatically found .env. |
ENIP_WRITE_PROBE_IDLE | 10 | Seconds. Before a write or set_plc_time on a CIP session idle at least this long, read the controller's identity first (a liveness probe that is safe to repeat); if it fails or the controller answers with an error, reconnect, then send the write once. 0 probes before every write. |
ENIP_DEADLINE | automatic | Seconds: the most one tool call may spend talking to the device, counted from the start of the call. Everything the call does shares it: waiting for the session, the liveness probe, reconnecting, retries, and every entry of a batch. Nothing is sent after it has passed: batch entries not yet sent then are not_sent (read_multiple_tags reports them as not read), and the one in flight is unknown. 0 or unset means ENIP_TIMEOUT × (ENIP_MAX_RETRIES + 2) + the backoff waits + 15 s (about 43 s with the defaults). When it expires, a read fails cleanly and a write is reported unknown (or not_sent if it was still connecting). Raise it if the first connection to a large controller (which uploads the whole tag list) takes longer. |
ENIP_DEBUG | false | Debug logging, including pycomm3's, on stderr. |
TAG_MAP_FILE | unset | JSON file of tag aliases with optional scaling, used by list_tags and the *_by_alias tools. |
The server connects once at startup. If the controller does not answer, it logs a warning on stderr and starts anyway; each tool call then reconnects and reports its own error, so the model sees success: false instead of a failed server.
A tag map maps an alias to a controller tag, optionally with linear scaling between raw and engineering units:
{
"motor_speed": {
"tag": "Program:MainProgram.MotorSpeed",
"data_type": "REAL",
"description": "Motor speed",
"scaling": { "raw_min": 0, "raw_max": 1800, "eng_min": 0, "eng_max": 100 }
}
}
read_tag_by_alias returns both raw_value and the scaled value; write_tag_by_alias takes engineering units and rounds the raw value for integer types (SINT to ULINT). A missing or malformed tag map file, a scaling with a zero span, or a non-numeric value on a scaled alias is reported as success: false.
Tools
All tools return { success, data, error, meta }. success is true only when the device confirmed the operation; meta carries the backend, the number of attempts and the duration.
- Tags:
read_tag,write_tag,read_array,write_array,read_string,write_string,read_multiple_tags,write_multiple_tags - Discovery:
get_tag_list(noprogram: controller-scoped tags;"*": all tags; a program name: that program's tags) - Tag map aliases:
list_tags,read_tag_by_alias,write_tag_by_alias - PLC info and time:
get_plc_info,get_plc_time,set_plc_time - Health:
ping(asks the device for its identity),get_connection_status(last known state, no I/O)
Some details:
- A read returns
data: {tag, value, data_type}. Array elements followpycomm3:read_array(orread_tagwithcount, or theTag{N}syntax) returns a list of N elements; reading an array tag without a count returns one element. - A write returns
data: {tag, value, data_type}, wheredata_typeis the type the device used. A list written withwrite_tagorwrite_arraygoes to consecutive elements (Tag{N}). Pass an object to write a structure. read_multiple_tagsreturns one{tag, value, data_type, error}entry per tag; if any read failed,successisfalseanddata.resultsshows which.write_multiple_tagsalways returnsdata.results, one{tag, value, data_type, error, outcome, request_sent}entry per payload.outcomeiswritten,rejected(the device refused it),unknown(it may have been applied; read it back) ornot_sent.successistrueonly if every entry was written. Batch writes are not atomic; on the JSON bridge the batch stops at the first connection failure and marks the restnot_sent.get_tag_listreturns{tag, data_type, dimensions, tag_type, alias, external_access, description}per tag on both backends.data_typeis the element type anddimensionsthe array size ([]for a scalar).descriptionis alwaysnullon a real controller, becausepycomm3does not read tag descriptions.- Bad arguments (a missing tag name,
elements: 0, an empty list, a duplicate tag in a batch) come back assuccess: falsewithInvalid arguments for <tool>: ..., not as a protocol error. Counts are strict integers:true,"2"and2.0are refused. - A boolean is never written into a numeric tag or structure member (
SINT…ULINT,REAL,LREAL):pycomm3would silently write 1 or 0, so the server refuses it before sending. Booleans are fine forBOOLtags,BOOLmembers and bits (Word.3). - A write to a one-dimensional array must fit: writing past its end (
Tank_Levels[2]with three values into a 3-element array) is refused before sending, so the controller can never apply part of it. - Tool annotations: the write tools and
set_plc_timeare markeddestructiveHint: trueandidempotentHint: false, so clients have no reason to retry them; the other tools arereadOnlyHint: true. get_plc_timereturnsdata: {plc_time, microseconds};set_plc_timesets the controller clock to this host's current time and needs bothENIP_WRITES_ENABLED=trueandENIP_SYSTEM_CMDS_ENABLED=true.
Writes are sent at most once
A write (write_tag, write_array, write_string, write_tag_by_alias, write_multiple_tags) or set_plc_time is never sent twice by the server. If opening the connection fails, nothing was sent and that part is retried. If the connection fails after the request may have gone out, for example because the reply was lost or timed out, the tool answers success: false with meta.outcome: "unknown" (per entry in a batch) and an error saying the write may have been applied. Read the tag back before writing again: re-sending could re-trigger a command or handshake bit.
Every write result says what happened: meta.outcome (per entry in a batch) is written, rejected, unknown or not_sent, and meta.request_sent is true for the first three and false for not_sent. On a real controller:
not_sent: refused before anything was sent, by the server's own checks (unknown tag, a boolean for a number, a write past the end of an array) or bypycomm3while building the request (a value it cannot encode for the tag's type).rejected: only for a CIP status that refuses the request before the controller executes it, such as Permission denied, Object does not exist, Invalid value or the Logix codes for a wrong data type or the controller being in download mode. The list is built frompycomm3's own status tables.unknown: everything else, including a garbled reply (Failed to parse reply),pycomm3'sUnknown Error, a large writepycomm3split into fragments that failed part-way, partial transfers, timeouts, the overall deadline expiring after sending, and an internal error after the write was attempted.
pycomm3 reports these cases as error text, so the classification follows pycomm3 1.2.14's messages (the dependency is pinned below 1.3). On the JSON bridge, the mock checks a request completely before applying it, so its refusals are rejected.
A write that has not been sent yet is also never sent after the MCP client gives up on it. This covers a write that is queued behind another call, still connecting, or waiting to retry, when the client cancels the call (notifications/cancelled) or disconnects (closes stdin). When the client disconnects, the server cancels every pending call, sends nothing more to the device, and exits. The last check before every send also reads the MCP request's own cancel flag, so a cancel that arrives in the very moment a connection or the session becomes free still stops the write. A write already on its way is not recalled. The client has been told the call was cancelled, so the server logs on stderr what happened instead: "nothing was sent", or the outcome if the request had already gone out.
On the CIP path, a session that has been idle for ENIP_WRITE_PROBE_IDLE seconds (default 10) is checked before a write with a cheap identity read. If the controller dropped it, the server reconnects first, so the write goes out once on a working session instead of failing as unknown.
Known limitations
- Not yet run against a physical controller. The
pycomm3calls are checked againstpycomm3's real signatures and behaviour as read from its source (1.2.14), but no CIP traffic has been exchanged with a Logix controller from this repository. Bench-test before relying on it. data_typeis not enforced on the CIP path.pycomm3always encodes a write with the controller's own type for the tag; it has no data type argument. If thedata_typeyou pass (or the tag map's) differs, the write still uses the controller's type and the response carries ameta.warning(or a per-entrywarningin batches). The mock refuses a mismatcheddata_type.ENIP_TIMEOUTdepends on apycomm3internal.pycomm31.2.14 has no setting for its socket timeout (itssocket_timeoutsetter writes a misspelled key), so the server sets_cfg["socket_timeout"]before connecting. The dependency is pinned topycomm3>=1.2.14,<1.3; a test fails if that changes.- A failing call can take a while. With the defaults, an unreachable controller that drops packets costs up to 4 attempts ×
ENIP_TIMEOUT(5 s) plus 3.5 s of backoff, about 24 s, before the tool answers; a refused connection fails within the 3.5 s of backoff. Each backoff wait is capped at 30 s. LowerENIP_MAX_RETRIES(for example0against the mock) for faster answers. - A session can still die between the probe and the write. The liveness probe catches a session dropped while idle, but if the connection breaks between the probe (or a recent call) and the write, the write fails with
outcome: "unknown"even if it never arrived, because the server cannot tell that apart from a lost reply. Read the tag and call the tool again. The probe itself (a connected Get_Attribute_Single of the identity object) has not been run against a physical controller yet. - The mock is not a controller. It speaks JSON over TCP, not CIP, and has no structures (UDTs), no array element indexing (
Tag[1]), and a single program. See its README. - mcp 1.x only. The server uses
mcp.server.fastmcp, which mcp 2.x renamed, somcpis pinned to<2. It also reads the request's cancel scope from mcp 1.x internals (session._in_flight[...]._cancel_scope); a test fails if they move.
Behaviour changes in this version
If you used an earlier commit, these changed (the old shapes were not kept):
- Gates: writes are off by default (
ENIP_WRITES_ENABLED=false), andset_plc_timeneeds bothENIP_WRITES_ENABLED=trueandENIP_SYSTEM_CMDS_ENABLED=true. An automatically found.envcan no longer turn either on; use the client'senvor--env-file. - Write results: single writes and
set_plc_timealways carrymeta.outcomeandmeta.request_sent; batch entries carryoutcomeandrequest_sent. Booleans are no longer accepted as counts or written into numeric tags or members. .env: onlyethernetip-python/.envof a source checkout is loaded implicitly (never the working directory or a parent), and only forENIP_*andTAG_MAP_FILEsettings.- Deadline: every tool call's exchange with the device is bounded by
ENIP_DEADLINE. - Retries: writes and
set_plc_timeare never re-sent once the request may have reached the device; a lost reply givessuccess: falsewithmeta.outcome: "unknown". - Settings:
ENIP_TIMEOUTdefaults to 5 s and now takes effect, as doENIP_PORTandENIP_MICRO800on the CIP path.ENIP_INIT_INFO,ENIP_CACHE_TAG_LISTandENIP_CACHE_TIMEOUT, which never did anything, are no longer read. Invalid settings stop the server at startup (exit code 2). The server no longer exits when the controller is unreachable at startup. read_tag/read_array:data: {tag, value, data_type}(pluselementswith a count) instead ofdata: {tag, result: {tag, value, data_type, status, error}}.write_tag/write_array/write_string:data: {tag, value, data_type}instead ofdata: {tag, written}.write_multiple_tags:data: {results: [{tag, value, data_type, error, outcome}]}instead ofdata: {written: [{tag, value, data_type}]}, andsuccess: falseif any entry was not written.read_multiple_tags:success: falseif any read failed (on the CIP path it used to betruewith errors inside the entries; on the bridge the first error failed the whole call and dropped the other results), and the entries no longer have astatusfield.list_tags: entries also carryscaling; a missing or malformed tag map file is an error instead of an empty list.read_string: fails on a value that is not a string, instead of converting it withstr().read_tag_by_alias/write_tag_by_alias:datahas the read or write fields plusalias,raw_value(controller units) andvalue(engineering units).get_tag_list: entries are{tag, data_type, dimensions, tag_type, alias, external_access, description}on both backends, instead of rawpycomm3definitions (which could not be serialised) or the mock's{tag, value, data_type, description}.get_plc_info:{name, vendor, product_type, product_code, product_name, revision, firmware, serial, keyswitch}, filled in (the fields used to benull).get_plc_time:{plc_time, microseconds}instead of a nested{plc_time: {...}}.set_plc_time:{updated, plc_time, microseconds}.ping: contacts the device and fails if it does not answer;datais{reachable, latency_ms, product_name, connection, writes_enabled, system_cmds_enabled, tag_aliases}.get_connection_status:pathis renamedroute; new fieldsbackend,last_contact,last_error, and on the CIP pathconnection_path,micro800_detectedandtimeout_s. On the JSON bridge the CIP-only fields (slot,route,micro800) are left out, anoteis added, andconnectedreflects the last request.- Bad arguments come back as
success: falseenvelopes instead of protocol-level tool errors. - The mock:
get_plc_infoand the clock tools work against it;get_tag_listwithoutprogramlists only its controller-scoped tags; reading an array tag without a count returns one element, as on a controller; writes are type-checked.
Planned Capabilities
- Tag operations: structures (UDTs) in the mock, module info
- Tag discovery: UDT definitions, module inventory
- Session control (
forward_open,forward_close) - A CIP front end for the mock PLC
Part of IndustriConnect
This server is one of the protocol servers in IndustriConnect, a suite of MCP servers for industrial protocols (Modbus, MQTT/Sparkplug B, OPC UA, BACnet, DNP3, EtherCAT, EtherNet/IP, PROFIBUS, PROFINET and S7comm) that share one response envelope and consistent tool naming. The suite includes this repository as the EtherNetIP-Project git submodule, tracking main. Development happens here: open issues and pull requests against this repository, not IndustriConnect.
Contributing
See CONTRIBUTING.md. To report a vulnerability, follow SECURITY.md, not a public issue. Everyone taking part agrees to the Code of Conduct.
License
MIT. See LICENSE.
Inspect get_connection_status before device work: its capabilities describe backend support and native configuration gates, not device permissions or reachability. See the protocol assessment for supported workflows and verification boundaries.
Additional protocol workflows
Controller tag browsing supports bounded pages with limit, prefix and cursor. Follow meta.next_cursor with the same scope; pages use a retained metadata snapshot, expire after 60 seconds and do not bound pycomm3 upload allocation.
See the protocol assessment and prioritized roadmap for limits, evidence and remaining work.