industriagents-profinet-mcp 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. industriagents_profinet_mcp-0.3.0/.env.example +41 -0
  2. industriagents_profinet_mcp-0.3.0/.gitignore +9 -0
  3. industriagents_profinet_mcp-0.3.0/.python-version +1 -0
  4. industriagents_profinet_mcp-0.3.0/PKG-INFO +204 -0
  5. industriagents_profinet_mcp-0.3.0/README.md +186 -0
  6. industriagents_profinet_mcp-0.3.0/pyproject.toml +54 -0
  7. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/__init__.py +12 -0
  8. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/_contract/tools.json +1233 -0
  9. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/adapters/__init__.py +1 -0
  10. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/adapters/pn_client.py +182 -0
  11. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/__init__.py +1 -0
  12. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/common.py +34 -0
  13. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/diagnostics.py +62 -0
  14. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/discovery.py +10 -0
  15. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/execution.py +82 -0
  16. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/metadata.py +46 -0
  17. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/operations.py +14 -0
  18. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/read.py +38 -0
  19. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/resources.py +16 -0
  20. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/write.py +70 -0
  21. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/backends.py +627 -0
  22. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/cli.py +185 -0
  23. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/config.py +124 -0
  24. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/dcp.py +320 -0
  25. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/__init__.py +1 -0
  26. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/errors.py +39 -0
  27. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/model.py +69 -0
  28. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/policy.py +34 -0
  29. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/ports.py +29 -0
  30. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/gsd_parser.py +477 -0
  31. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/infrastructure/__init__.py +1 -0
  32. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/infrastructure/audit.py +45 -0
  33. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/infrastructure/maps.py +67 -0
  34. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/pn_client.py +7 -0
  35. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/__init__.py +1 -0
  36. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/contract.py +158 -0
  37. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/support.py +33 -0
  38. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/tools.py +248 -0
  39. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/server.py +122 -0
  40. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/tools.py +62 -0
  41. industriagents_profinet_mcp-0.3.0/src/profinet_mcp/validation.py +113 -0
  42. industriagents_profinet_mcp-0.3.0/tests/conftest.py +21 -0
  43. industriagents_profinet_mcp-0.3.0/tests/data/GSDML-V2.35-IndustriAgents-SampleIO-20260101.xml +247 -0
  44. industriagents_profinet_mcp-0.3.0/tests/helpers.py +129 -0
  45. industriagents_profinet_mcp-0.3.0/tests/test_architecture_contract.py +184 -0
  46. industriagents_profinet_mcp-0.3.0/tests/test_backends.py +586 -0
  47. industriagents_profinet_mcp-0.3.0/tests/test_cli.py +78 -0
  48. industriagents_profinet_mcp-0.3.0/tests/test_config.py +304 -0
  49. industriagents_profinet_mcp-0.3.0/tests/test_dcp.py +302 -0
  50. industriagents_profinet_mcp-0.3.0/tests/test_gsd_parser.py +505 -0
  51. industriagents_profinet_mcp-0.3.0/tests/test_integration.py +471 -0
  52. industriagents_profinet_mcp-0.3.0/tests/test_tools.py +1090 -0
  53. industriagents_profinet_mcp-0.3.0/uv.lock +962 -0
@@ -0,0 +1,41 @@
1
+ # Example settings for profinet-mcp.
2
+ #
3
+ # Where settings come from:
4
+ # 1. the real environment (e.g. the MCP client's "env" block) - always wins;
5
+ # 2. a file passed explicitly with `profinet-mcp --env-file PATH`;
6
+ # 3. otherwise profinet-python/.env of a source checkout (copy this file there).
7
+ # A .env in the working directory or any parent is NEVER read: when an MCP client
8
+ # launches the server, that directory is the client's project, which may not be trusted.
9
+ # An installed package reads no implicit .env at all.
10
+ #
11
+ # profinet-python/.env may set only PROFINET_* settings and DEVICE_MAP_FILE (keys are
12
+ # case-insensitive). Anything else, and every safety switch, is ignored with a warning.
13
+
14
+ # Backend: none (default, contacts nothing) | mock | network
15
+ PROFINET_BACKEND=mock
16
+
17
+ # mock backend: where profinet-mock-server listens
18
+ PROFINET_MOCK_HOST=127.0.0.1
19
+ PROFINET_MOCK_PORT=5600
20
+
21
+ # network backend: Ethernet interface on the PROFINET network (DCP discovery needs
22
+ # raw-socket privileges on it)
23
+ # PROFINET_INTERFACE=eth0
24
+
25
+ # Timing
26
+ # PROFINET_DCP_TIMEOUT=5 # default discovery time in seconds (0.5-60)
27
+ # PROFINET_TIMEOUT=5 # per connect / reply to the mock, in seconds
28
+ # PROFINET_MAX_RETRIES=3 # extra connection attempts (reads may also be re-sent)
29
+ # PROFINET_RETRY_BACKOFF_BASE=0.5
30
+
31
+ # Aliases and GSDML files
32
+ # DEVICE_MAP_FILE=devices.json
33
+ # PROFINET_GSD_PATH=gsdml/
34
+
35
+ # --- Safety switches -------------------------------------------------------------------
36
+ # Honoured ONLY from the real process environment or a file given with --env-file.
37
+ # In profinet-python/.env they are ignored, with a warning on stderr.
38
+ #
39
+ # PROFINET_WRITES_ENABLED=false # true allows write_io_data / write_device_by_alias
40
+ # PROFINET_CONFIG_CMDS_ENABLED=false # set_device_name, set_device_ip, identify_device
41
+ # # (they also need PROFINET_WRITES_ENABLED=true)
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ .env
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ __pycache__/
7
+ *.py[cod]
8
+ dist/
9
+ build/
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.5
2
+ Name: industriagents-profinet-mcp
3
+ Version: 0.3.0
4
+ Summary: MCP server for PROFINET IO: DCP discovery, GSDML parsing, and a full tool set against a mock IO device.
5
+ Project-URL: Repository, https://github.com/IndustriAgents/PROFINET-MCP
6
+ Project-URL: Issues, https://github.com/IndustriAgents/PROFINET-MCP/issues
7
+ Author: IndustriAgents
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: anyio>=4.7.0
11
+ Requires-Dist: jsonschema<5,>=4.23
12
+ Requires-Dist: lxml>=4.9.2
13
+ Requires-Dist: mcp[cli]<2,>=1.22.0
14
+ Requires-Dist: pydantic<2.13,>=2.11.0
15
+ Requires-Dist: python-dotenv>=1.1.0
16
+ Requires-Dist: scapy>=2.5.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ > **0.3.0 package and tool migration:** Install `industriagents-profinet-mcp`
20
+ > from npm (requires uv) or PyPI; the command remains `profinet-mcp`.
21
+ > The advertised catalog now contains **11 tools**. Older tool names in
22
+ > the examples below remain compatibility calls. See the
23
+ > [architecture and canonical tool guide](../docs/architecture.md)
24
+ > and [release guide](../docs/release.md).
25
+
26
+ # profinet-python
27
+
28
+ Python MCP server for PROFINET IO networks. Built with `uv`, `mcp[cli]`,
29
+ `scapy` and `lxml`, it exposes the `profinet-mcp` CLI for AI agents and MCP
30
+ clients. Which tools work depends on the backend; see
31
+ [Status](../README.md#status) in the repository README.
32
+
33
+ ## Requirements
34
+
35
+ - Python 3.11+
36
+ - [uv](https://github.com/astral-sh/uv)
37
+ - For `PROFINET_BACKEND=mock`: the [mock IO device](../profinet-mock-server/README.md)
38
+ - For `PROFINET_BACKEND=network`: an Ethernet interface on the PROFINET network
39
+ and raw-socket privileges on it (root or `CAP_NET_RAW` on Linux, `/dev/bpf*`
40
+ access on macOS)
41
+
42
+ ## Quick start
43
+
44
+ From the repository root:
45
+
46
+ ```bash
47
+ uv sync --directory profinet-python
48
+ PROFINET_BACKEND=mock uv run --directory profinet-python profinet-mcp # against the mock
49
+ uv run --directory profinet-python profinet-mcp --help # options and settings
50
+ ```
51
+
52
+ The server speaks MCP over stdio, so it waits for a client on stdin; see the
53
+ [repository README](../README.md#quick-start) for an MCP client configuration.
54
+ Only JSON-RPC goes to stdout; logs go to stderr.
55
+
56
+ ## Backends
57
+
58
+ | `PROFINET_BACKEND` | Behaviour |
59
+ |--------------------|-----------|
60
+ | `none` (default) | Contacts nothing. Device tools return `success: false` explaining how to choose a backend. |
61
+ | `mock` | Sends the mock's JSON-lines requests to `PROFINET_MOCK_HOST:PROFINET_MOCK_PORT`. One TCP connection per call; answers are checked (byte counts, confirmations) before a tool reports success. |
62
+ | `network` | DCP Identify All on `PROFINET_INTERFACE` for `discover_devices`, `get_device_info` and `test_device_communication`, at most once every 2 s. I/O and DCP Set tools return `success: false` ("Not implemented ...") and send nothing. |
63
+
64
+ `meta.backend` in each envelope names the backend that answered.
65
+
66
+ Tools that change a device (`write_io_data`, `write_device_by_alias`,
67
+ `set_device_name`, `set_device_ip`, `identify_device`) also report
68
+ `meta.outcome` and `meta.request_sent`:
69
+
70
+ | `outcome` | `request_sent` | Meaning |
71
+ |-----------|----------------|---------|
72
+ | `written` | `true` | The device confirmed the change |
73
+ | `rejected` | `true` | The device received the request and refused it |
74
+ | `unknown` | `true` | Sent, but no usable reply: it may have been applied; check before retrying. It is never re-sent automatically. |
75
+ | `not_sent` | `false` | Refused by a switch, invalid arguments, no backend, backend unreachable, or not implemented |
76
+
77
+ Connecting to the mock is retried (`PROFINET_MAX_RETRIES`); a read whose reply
78
+ is lost is sent again, but a write or DCP command never is. "rejected" is used
79
+ only when the mock marks a refusal `"rejected": true` (refused before anything
80
+ changed); any other failure after a send is "unknown".
81
+
82
+ Every tool call has **one absolute deadline**, fixed when the call starts
83
+ (all connect and reply timeouts, retries and backoff, plus slack: 45.5 s with
84
+ the defaults) and shared by every request the call makes; it is never
85
+ restarted per request. Waiting to reach a busy device counts against it, and
86
+ the deadline is checked again right before each send, so a change is never
87
+ sent after it: a request that never left is `not_sent`, a change that did is
88
+ `unknown`. There is no lock or queue in the server itself (each mock request
89
+ uses its own connection, and the DCP rate limit refuses rather than queues).
90
+
91
+ When the MCP client cancels a call (`notifications/cancelled`), a change that
92
+ has not been sent yet is never sent: the server captures the request's cancel
93
+ scope when the call starts and checks it right before each send, because the
94
+ SDK only sets a flag and anyio may let the call run one more step first.
95
+
96
+ When the MCP client disconnects (stdin closes), pending requests are stopped:
97
+ one still connecting or waiting to retry is never sent (`not_sent`), one
98
+ already sent is not repeated, no new request starts, and the server exits with
99
+ status 0.
100
+
101
+ ## Configuration
102
+
103
+ Set these as environment variables, for example in your MCP client's `env`
104
+ block, or in a file passed with `--env-file PATH`. Values already in the
105
+ environment win over the file. Settings are read when the server starts, and
106
+ an unusable value (for example `PROFINET_WRITES_ENABLED=maybe`) stops it with
107
+ a message on stderr. [`.env.example`](.env.example) lists them all.
108
+
109
+ Without `--env-file`, the server also reads **`profinet-python/.env`** of a
110
+ source checkout, found relative to the package source. It never reads a `.env`
111
+ from the working directory or its parents (when an MCP client starts the
112
+ server, that is the client's project, which may not be trusted; settings such
113
+ as the mock address or the device map decide where a write goes), and an
114
+ installed package reads no implicit `.env` at all. That file is applied
115
+ defensively:
116
+
117
+ - **safety switches (`PROFINET_*_ENABLED`) in it are ignored** - they come
118
+ only from the real environment or `--env-file`;
119
+ - it may set only `PROFINET_*` settings and `DEVICE_MAP_FILE`; anything else
120
+ (`PATH`, `LD_PRELOAD`, `PYTHONPATH`, ...) is ignored;
121
+ - keys are compared case-insensitively (as Windows does), with `export` and
122
+ quotes handled;
123
+ - each ignored key is named in a warning on stderr.
124
+
125
+ | Variable | Default | Meaning |
126
+ |----------|---------|---------|
127
+ | `PROFINET_BACKEND` | `none` | `none`, `mock` or `network`, see above |
128
+ | `PROFINET_WRITES_ENABLED` | `false` | `true` allows tools that change a device (`write_io_data`, `write_device_by_alias`; also required by the DCP commands) |
129
+ | `PROFINET_CONFIG_CMDS_ENABLED` | `false` | `true` (together with `PROFINET_WRITES_ENABLED`) allows `set_device_name`, `set_device_ip` and `identify_device` |
130
+ | `PROFINET_MOCK_HOST` | `127.0.0.1` | Address of the mock IO device |
131
+ | `PROFINET_MOCK_PORT` | `5600` | Port of the mock IO device |
132
+ | `PROFINET_INTERFACE` | `eth0` | Ethernet interface for DCP discovery (`network` backend) |
133
+ | `PROFINET_DCP_TIMEOUT` | `5` | Default discovery time, in seconds (0.5-60) |
134
+ | `PROFINET_TIMEOUT` | `5` | Timeout for each connect and each reply from the mock, in seconds |
135
+ | `PROFINET_MAX_RETRIES` | `3` | Extra connection attempts to the mock, and re-sends of a read whose reply was lost (0-10); changes are never re-sent |
136
+ | `PROFINET_RETRY_BACKOFF_BASE` | `0.5` | First retry delay in seconds; doubles each attempt |
137
+ | `PROFINET_CONTROLLER_IP` | `192.168.1.1` | Reported by `ping` only |
138
+ | `PROFINET_NETWORK` | `192.168.1.0/24` | Reported by `ping` only |
139
+ | `PROFINET_DCP_PORT` | `34964` | Reported by `ping` only (DCP itself is layer 2 and has no port) |
140
+ | `DEVICE_MAP_FILE` | unset | JSON file of device aliases, see below |
141
+ | `PROFINET_GSD_PATH` | unset | Directory searched by `load_gsd_file` for relative paths |
142
+
143
+ Booleans accept `true`/`false`, `1`/`0`, `yes`/`no`, `y`/`n`, `on`/`off` and
144
+ `t`/`f`.
145
+
146
+ `DEVICE_MAP_FILE` maps aliases to a device and slot, used by `list_devices`,
147
+ `read_device_by_alias` and `write_device_by_alias`:
148
+
149
+ ```json
150
+ {
151
+ "line1_inputs": {
152
+ "device_name": "pn-device-01",
153
+ "ip_address": "192.168.1.100",
154
+ "description": "Line 1 digital inputs",
155
+ "slot": 1,
156
+ "subslot": 1,
157
+ "data_length": 8
158
+ }
159
+ }
160
+ ```
161
+
162
+ `slot` is required (an alias without one is reported as an error rather than
163
+ guessed); `subslot` defaults to `1` and `data_length` to `8`. The file is
164
+ re-read when it changes; if it cannot be parsed, `list_devices` and `ping`
165
+ report the error.
166
+
167
+ ## Structure
168
+
169
+ ```
170
+ profinet-python/
171
+ ├── pyproject.toml
172
+ ├── uv.lock
173
+ ├── .env.example # every setting; safety switches only via the real env or --env-file
174
+ ├── .python-version
175
+ ├── src/profinet_mcp
176
+ │ ├── __init__.py
177
+ │ ├── cli.py # argument parsing, .env loading, startup
178
+ │ ├── config.py # ProfinetConfig.from_env()
179
+ │ ├── server.py # FastMCP wiring; bad arguments -> envelopes
180
+ │ ├── tools.py # the 14 MCP tools
181
+ │ ├── validation.py # MAC, station name, IP suite, slot checks
182
+ │ ├── pn_client.py # safety switches, timing, routing to the backend
183
+ │ ├── backends.py # none / mock (JSON over TCP) / network (DCP)
184
+ │ ├── dcp.py # DCP Identify codec and Scapy layer-2 transport
185
+ │ └── gsd_parser.py # GSDML parsing and cache
186
+ └── tests/ # pytest: unit tests and mock-backed integration tests
187
+ ```
188
+
189
+ ## Tests
190
+
191
+ ```bash
192
+ cd profinet-python
193
+ uv sync --locked
194
+ uv run --no-sync ruff check . && uv run --no-sync ruff format --check .
195
+ uv run --no-sync pytest
196
+ ```
197
+
198
+ The integration tests start `../profinet-mock-server` on an OS-assigned port
199
+ and drive the server over stdio with the MCP client SDK. No test opens a raw
200
+ socket: DCP is tested against fake layer-2 transports. The safety tests are
201
+ mutation-checked: each one fails when the protection it covers is removed.
202
+
203
+ Dependency floors in `pyproject.toml` are the lowest versions the suite passes
204
+ on; CI's `lowest-direct` job checks that with `--resolution lowest-direct`.
@@ -0,0 +1,186 @@
1
+ > **0.3.0 package and tool migration:** Install `industriagents-profinet-mcp`
2
+ > from npm (requires uv) or PyPI; the command remains `profinet-mcp`.
3
+ > The advertised catalog now contains **11 tools**. Older tool names in
4
+ > the examples below remain compatibility calls. See the
5
+ > [architecture and canonical tool guide](../docs/architecture.md)
6
+ > and [release guide](../docs/release.md).
7
+
8
+ # profinet-python
9
+
10
+ Python MCP server for PROFINET IO networks. Built with `uv`, `mcp[cli]`,
11
+ `scapy` and `lxml`, it exposes the `profinet-mcp` CLI for AI agents and MCP
12
+ clients. Which tools work depends on the backend; see
13
+ [Status](../README.md#status) in the repository README.
14
+
15
+ ## Requirements
16
+
17
+ - Python 3.11+
18
+ - [uv](https://github.com/astral-sh/uv)
19
+ - For `PROFINET_BACKEND=mock`: the [mock IO device](../profinet-mock-server/README.md)
20
+ - For `PROFINET_BACKEND=network`: an Ethernet interface on the PROFINET network
21
+ and raw-socket privileges on it (root or `CAP_NET_RAW` on Linux, `/dev/bpf*`
22
+ access on macOS)
23
+
24
+ ## Quick start
25
+
26
+ From the repository root:
27
+
28
+ ```bash
29
+ uv sync --directory profinet-python
30
+ PROFINET_BACKEND=mock uv run --directory profinet-python profinet-mcp # against the mock
31
+ uv run --directory profinet-python profinet-mcp --help # options and settings
32
+ ```
33
+
34
+ The server speaks MCP over stdio, so it waits for a client on stdin; see the
35
+ [repository README](../README.md#quick-start) for an MCP client configuration.
36
+ Only JSON-RPC goes to stdout; logs go to stderr.
37
+
38
+ ## Backends
39
+
40
+ | `PROFINET_BACKEND` | Behaviour |
41
+ |--------------------|-----------|
42
+ | `none` (default) | Contacts nothing. Device tools return `success: false` explaining how to choose a backend. |
43
+ | `mock` | Sends the mock's JSON-lines requests to `PROFINET_MOCK_HOST:PROFINET_MOCK_PORT`. One TCP connection per call; answers are checked (byte counts, confirmations) before a tool reports success. |
44
+ | `network` | DCP Identify All on `PROFINET_INTERFACE` for `discover_devices`, `get_device_info` and `test_device_communication`, at most once every 2 s. I/O and DCP Set tools return `success: false` ("Not implemented ...") and send nothing. |
45
+
46
+ `meta.backend` in each envelope names the backend that answered.
47
+
48
+ Tools that change a device (`write_io_data`, `write_device_by_alias`,
49
+ `set_device_name`, `set_device_ip`, `identify_device`) also report
50
+ `meta.outcome` and `meta.request_sent`:
51
+
52
+ | `outcome` | `request_sent` | Meaning |
53
+ |-----------|----------------|---------|
54
+ | `written` | `true` | The device confirmed the change |
55
+ | `rejected` | `true` | The device received the request and refused it |
56
+ | `unknown` | `true` | Sent, but no usable reply: it may have been applied; check before retrying. It is never re-sent automatically. |
57
+ | `not_sent` | `false` | Refused by a switch, invalid arguments, no backend, backend unreachable, or not implemented |
58
+
59
+ Connecting to the mock is retried (`PROFINET_MAX_RETRIES`); a read whose reply
60
+ is lost is sent again, but a write or DCP command never is. "rejected" is used
61
+ only when the mock marks a refusal `"rejected": true` (refused before anything
62
+ changed); any other failure after a send is "unknown".
63
+
64
+ Every tool call has **one absolute deadline**, fixed when the call starts
65
+ (all connect and reply timeouts, retries and backoff, plus slack: 45.5 s with
66
+ the defaults) and shared by every request the call makes; it is never
67
+ restarted per request. Waiting to reach a busy device counts against it, and
68
+ the deadline is checked again right before each send, so a change is never
69
+ sent after it: a request that never left is `not_sent`, a change that did is
70
+ `unknown`. There is no lock or queue in the server itself (each mock request
71
+ uses its own connection, and the DCP rate limit refuses rather than queues).
72
+
73
+ When the MCP client cancels a call (`notifications/cancelled`), a change that
74
+ has not been sent yet is never sent: the server captures the request's cancel
75
+ scope when the call starts and checks it right before each send, because the
76
+ SDK only sets a flag and anyio may let the call run one more step first.
77
+
78
+ When the MCP client disconnects (stdin closes), pending requests are stopped:
79
+ one still connecting or waiting to retry is never sent (`not_sent`), one
80
+ already sent is not repeated, no new request starts, and the server exits with
81
+ status 0.
82
+
83
+ ## Configuration
84
+
85
+ Set these as environment variables, for example in your MCP client's `env`
86
+ block, or in a file passed with `--env-file PATH`. Values already in the
87
+ environment win over the file. Settings are read when the server starts, and
88
+ an unusable value (for example `PROFINET_WRITES_ENABLED=maybe`) stops it with
89
+ a message on stderr. [`.env.example`](.env.example) lists them all.
90
+
91
+ Without `--env-file`, the server also reads **`profinet-python/.env`** of a
92
+ source checkout, found relative to the package source. It never reads a `.env`
93
+ from the working directory or its parents (when an MCP client starts the
94
+ server, that is the client's project, which may not be trusted; settings such
95
+ as the mock address or the device map decide where a write goes), and an
96
+ installed package reads no implicit `.env` at all. That file is applied
97
+ defensively:
98
+
99
+ - **safety switches (`PROFINET_*_ENABLED`) in it are ignored** - they come
100
+ only from the real environment or `--env-file`;
101
+ - it may set only `PROFINET_*` settings and `DEVICE_MAP_FILE`; anything else
102
+ (`PATH`, `LD_PRELOAD`, `PYTHONPATH`, ...) is ignored;
103
+ - keys are compared case-insensitively (as Windows does), with `export` and
104
+ quotes handled;
105
+ - each ignored key is named in a warning on stderr.
106
+
107
+ | Variable | Default | Meaning |
108
+ |----------|---------|---------|
109
+ | `PROFINET_BACKEND` | `none` | `none`, `mock` or `network`, see above |
110
+ | `PROFINET_WRITES_ENABLED` | `false` | `true` allows tools that change a device (`write_io_data`, `write_device_by_alias`; also required by the DCP commands) |
111
+ | `PROFINET_CONFIG_CMDS_ENABLED` | `false` | `true` (together with `PROFINET_WRITES_ENABLED`) allows `set_device_name`, `set_device_ip` and `identify_device` |
112
+ | `PROFINET_MOCK_HOST` | `127.0.0.1` | Address of the mock IO device |
113
+ | `PROFINET_MOCK_PORT` | `5600` | Port of the mock IO device |
114
+ | `PROFINET_INTERFACE` | `eth0` | Ethernet interface for DCP discovery (`network` backend) |
115
+ | `PROFINET_DCP_TIMEOUT` | `5` | Default discovery time, in seconds (0.5-60) |
116
+ | `PROFINET_TIMEOUT` | `5` | Timeout for each connect and each reply from the mock, in seconds |
117
+ | `PROFINET_MAX_RETRIES` | `3` | Extra connection attempts to the mock, and re-sends of a read whose reply was lost (0-10); changes are never re-sent |
118
+ | `PROFINET_RETRY_BACKOFF_BASE` | `0.5` | First retry delay in seconds; doubles each attempt |
119
+ | `PROFINET_CONTROLLER_IP` | `192.168.1.1` | Reported by `ping` only |
120
+ | `PROFINET_NETWORK` | `192.168.1.0/24` | Reported by `ping` only |
121
+ | `PROFINET_DCP_PORT` | `34964` | Reported by `ping` only (DCP itself is layer 2 and has no port) |
122
+ | `DEVICE_MAP_FILE` | unset | JSON file of device aliases, see below |
123
+ | `PROFINET_GSD_PATH` | unset | Directory searched by `load_gsd_file` for relative paths |
124
+
125
+ Booleans accept `true`/`false`, `1`/`0`, `yes`/`no`, `y`/`n`, `on`/`off` and
126
+ `t`/`f`.
127
+
128
+ `DEVICE_MAP_FILE` maps aliases to a device and slot, used by `list_devices`,
129
+ `read_device_by_alias` and `write_device_by_alias`:
130
+
131
+ ```json
132
+ {
133
+ "line1_inputs": {
134
+ "device_name": "pn-device-01",
135
+ "ip_address": "192.168.1.100",
136
+ "description": "Line 1 digital inputs",
137
+ "slot": 1,
138
+ "subslot": 1,
139
+ "data_length": 8
140
+ }
141
+ }
142
+ ```
143
+
144
+ `slot` is required (an alias without one is reported as an error rather than
145
+ guessed); `subslot` defaults to `1` and `data_length` to `8`. The file is
146
+ re-read when it changes; if it cannot be parsed, `list_devices` and `ping`
147
+ report the error.
148
+
149
+ ## Structure
150
+
151
+ ```
152
+ profinet-python/
153
+ ├── pyproject.toml
154
+ ├── uv.lock
155
+ ├── .env.example # every setting; safety switches only via the real env or --env-file
156
+ ├── .python-version
157
+ ├── src/profinet_mcp
158
+ │ ├── __init__.py
159
+ │ ├── cli.py # argument parsing, .env loading, startup
160
+ │ ├── config.py # ProfinetConfig.from_env()
161
+ │ ├── server.py # FastMCP wiring; bad arguments -> envelopes
162
+ │ ├── tools.py # the 14 MCP tools
163
+ │ ├── validation.py # MAC, station name, IP suite, slot checks
164
+ │ ├── pn_client.py # safety switches, timing, routing to the backend
165
+ │ ├── backends.py # none / mock (JSON over TCP) / network (DCP)
166
+ │ ├── dcp.py # DCP Identify codec and Scapy layer-2 transport
167
+ │ └── gsd_parser.py # GSDML parsing and cache
168
+ └── tests/ # pytest: unit tests and mock-backed integration tests
169
+ ```
170
+
171
+ ## Tests
172
+
173
+ ```bash
174
+ cd profinet-python
175
+ uv sync --locked
176
+ uv run --no-sync ruff check . && uv run --no-sync ruff format --check .
177
+ uv run --no-sync pytest
178
+ ```
179
+
180
+ The integration tests start `../profinet-mock-server` on an OS-assigned port
181
+ and drive the server over stdio with the MCP client SDK. No test opens a raw
182
+ socket: DCP is tested against fake layer-2 transports. The safety tests are
183
+ mutation-checked: each one fails when the protection it covers is removed.
184
+
185
+ Dependency floors in `pyproject.toml` are the lowest versions the suite passes
186
+ on; CI's `lowest-direct` job checks that with `--resolution lowest-direct`.
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "industriagents-profinet-mcp"
3
+ version = "0.3.0"
4
+ description = "MCP server for PROFINET IO: DCP discovery, GSDML parsing, and a full tool set against a mock IO device."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "IndustriAgents" }]
9
+ # Floors are the lowest versions the test suite passes on (CI job "lowest-direct").
10
+ dependencies = [
11
+ "jsonschema>=4.23,<5",
12
+ "anyio>=4.7.0",
13
+ # Contract wire errors require CallToolResult passthrough (tested on 1.22+).
14
+ "mcp[cli]>=1.22.0,<2", # uses FastMCP internals (see server.py)
15
+ # The supported MCP 1.x floor uses typing helpers removed in Pydantic 2.13.
16
+ "pydantic>=2.11.0,<2.13",
17
+ "python-dotenv>=1.1.0",
18
+ "scapy>=2.5.0",
19
+ "lxml>=4.9.2",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/IndustriAgents/PROFINET-MCP"
24
+ Issues = "https://github.com/IndustriAgents/PROFINET-MCP/issues"
25
+
26
+ [project.scripts]
27
+ profinet-mcp = "profinet_mcp.cli:main"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8.2.0",
32
+ "ruff>=0.6.0",
33
+ # The integration tests run ../profinet-mock-server in this environment.
34
+ "rich>=13.8.0",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ addopts = "-ra"
44
+ markers = ["integration: starts the mock IO device and the MCP server as subprocesses"]
45
+
46
+ [tool.ruff]
47
+ line-length = 120
48
+ target-version = "py311"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "W", "I", "B", "UP"]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/profinet_mcp"]
@@ -0,0 +1,12 @@
1
+ """PROFINET MCP server package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("industriagents-profinet-mcp")
7
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without installing
8
+ __version__ = "0.3.0"
9
+
10
+ from .server import ProfinetMCPServer # noqa: E402 (needs __version__)
11
+
12
+ __all__ = ["ProfinetMCPServer", "__version__"]