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.
- industriagents_profinet_mcp-0.3.0/.env.example +41 -0
- industriagents_profinet_mcp-0.3.0/.gitignore +9 -0
- industriagents_profinet_mcp-0.3.0/.python-version +1 -0
- industriagents_profinet_mcp-0.3.0/PKG-INFO +204 -0
- industriagents_profinet_mcp-0.3.0/README.md +186 -0
- industriagents_profinet_mcp-0.3.0/pyproject.toml +54 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/__init__.py +12 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/_contract/tools.json +1233 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/adapters/__init__.py +1 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/adapters/pn_client.py +182 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/__init__.py +1 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/common.py +34 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/diagnostics.py +62 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/discovery.py +10 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/execution.py +82 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/metadata.py +46 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/operations.py +14 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/read.py +38 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/resources.py +16 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/application/write.py +70 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/backends.py +627 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/cli.py +185 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/config.py +124 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/dcp.py +320 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/__init__.py +1 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/errors.py +39 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/model.py +69 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/policy.py +34 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/domain/ports.py +29 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/gsd_parser.py +477 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/infrastructure/__init__.py +1 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/infrastructure/audit.py +45 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/infrastructure/maps.py +67 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/pn_client.py +7 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/__init__.py +1 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/contract.py +158 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/support.py +33 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/protocol/tools.py +248 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/server.py +122 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/tools.py +62 -0
- industriagents_profinet_mcp-0.3.0/src/profinet_mcp/validation.py +113 -0
- industriagents_profinet_mcp-0.3.0/tests/conftest.py +21 -0
- industriagents_profinet_mcp-0.3.0/tests/data/GSDML-V2.35-IndustriAgents-SampleIO-20260101.xml +247 -0
- industriagents_profinet_mcp-0.3.0/tests/helpers.py +129 -0
- industriagents_profinet_mcp-0.3.0/tests/test_architecture_contract.py +184 -0
- industriagents_profinet_mcp-0.3.0/tests/test_backends.py +586 -0
- industriagents_profinet_mcp-0.3.0/tests/test_cli.py +78 -0
- industriagents_profinet_mcp-0.3.0/tests/test_config.py +304 -0
- industriagents_profinet_mcp-0.3.0/tests/test_dcp.py +302 -0
- industriagents_profinet_mcp-0.3.0/tests/test_gsd_parser.py +505 -0
- industriagents_profinet_mcp-0.3.0/tests/test_integration.py +471 -0
- industriagents_profinet_mcp-0.3.0/tests/test_tools.py +1090 -0
- 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 @@
|
|
|
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__"]
|