opcua-mcp-server 0.2.1__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.
- opcua_mcp_server-0.2.1/.gitignore +42 -0
- opcua_mcp_server-0.2.1/PKG-INFO +157 -0
- opcua_mcp_server-0.2.1/README.md +146 -0
- opcua_mcp_server-0.2.1/contract/tools.json +197 -0
- opcua_mcp_server-0.2.1/hatch_build.py +46 -0
- opcua_mcp_server-0.2.1/pyproject.toml +39 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/__init__.py +27 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/aggregates.py +58 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/capabilities.py +65 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/config.py +8 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/contract.py +42 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/datetimes.py +22 -0
- opcua_mcp_server-0.2.1/src/opcua_mcp_server/server.py +511 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
.AppleDouble
|
|
4
|
+
.LSOverride
|
|
5
|
+
|
|
6
|
+
# Windows
|
|
7
|
+
Thumbs.db
|
|
8
|
+
ehthumbs.db
|
|
9
|
+
Desktop.ini
|
|
10
|
+
|
|
11
|
+
# IDE
|
|
12
|
+
.vscode/
|
|
13
|
+
.idea/
|
|
14
|
+
*.swp
|
|
15
|
+
*.swo
|
|
16
|
+
|
|
17
|
+
# Logs
|
|
18
|
+
*.log
|
|
19
|
+
|
|
20
|
+
# Python
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.py[oc]
|
|
23
|
+
*.egg-info/
|
|
24
|
+
build/
|
|
25
|
+
dist/
|
|
26
|
+
wheels/
|
|
27
|
+
.venv/
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
|
|
30
|
+
# Node / TypeScript
|
|
31
|
+
node_modules/
|
|
32
|
+
*.tsbuildinfo
|
|
33
|
+
.npm
|
|
34
|
+
*.tgz
|
|
35
|
+
coverage/
|
|
36
|
+
.eslintcache
|
|
37
|
+
.env
|
|
38
|
+
.env.*
|
|
39
|
+
|
|
40
|
+
# Local Claude Code config (machine-specific)
|
|
41
|
+
.mcp.json
|
|
42
|
+
.claude/settings.local.json
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: opcua-mcp-server
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: MCP server exposing OPC UA read/write/browse/method/history tools (FastMCP + python-opcua).
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: cryptography>=45.0.2
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: mcp[cli]<2,>=1.9.1
|
|
9
|
+
Requires-Dist: opcua>=0.98.13
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# OPC UA MCP Server
|
|
13
|
+
|
|
14
|
+
A Model Context Protocol (MCP) server that provides seamless integration with OPC UA servers. This server enables AI assistants and other MCP clients to interact with industrial automation systems through standardized OPC UA communication protocols.
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
This MCP server acts as a bridge between AI assistants and OPC UA servers, allowing for:
|
|
19
|
+
- Reading sensor data and system variables
|
|
20
|
+
- Writing control values to actuators and systems
|
|
21
|
+
- Browsing OPC UA node hierarchies
|
|
22
|
+
- Calling OPC UA methods for system operations
|
|
23
|
+
- Batch operations for multiple nodes
|
|
24
|
+
|
|
25
|
+
## Tools
|
|
26
|
+
|
|
27
|
+
See the central per-tool reference in **[../../docs/examples.md](../../docs/examples.md)**; the shared tool surface is defined in **[../../contract/tools.json](../../contract/tools.json)**.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
### Key Capabilities
|
|
32
|
+
|
|
33
|
+
- **Automatic Connection Management**: Handles OPC UA client lifecycle with proper connection setup and teardown
|
|
34
|
+
- **Type-Safe Operations**: Automatic type conversion based on existing node data types
|
|
35
|
+
- **Error Handling**: Comprehensive error reporting for debugging and monitoring
|
|
36
|
+
- **Async Support**: Built on FastMCP for efficient asynchronous operations
|
|
37
|
+
- **Configurable**: Environment-based server URL configuration
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
### Prerequisites
|
|
42
|
+
|
|
43
|
+
- Python 3.13 or higher
|
|
44
|
+
- Access to an OPC UA server (local or remote)
|
|
45
|
+
- UV package manager (recommended) or pip
|
|
46
|
+
|
|
47
|
+
### Setup
|
|
48
|
+
|
|
49
|
+
1. **Install dependencies for the whole workspace (run from the repo root):**
|
|
50
|
+
```bash
|
|
51
|
+
uv sync --all-packages
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
2. **Configure the OPC UA server URL:**
|
|
55
|
+
```bash
|
|
56
|
+
export OPCUA_SERVER_URL="opc.tcp://localhost:4840"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Running the Server
|
|
62
|
+
|
|
63
|
+
After `uv sync --all-packages` from the repo root:
|
|
64
|
+
```bash
|
|
65
|
+
uv run --no-sync opcua-mcp-server
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or run it directly against this package from anywhere in the repo:
|
|
69
|
+
```bash
|
|
70
|
+
uv --directory packages/server-python run opcua-mcp-server
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Integration with MCP Clients
|
|
74
|
+
|
|
75
|
+
Add to your MCP client configuration (e.g., `config.json`):
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"opcua-mcp": {
|
|
81
|
+
"command": "/path/to/uv",
|
|
82
|
+
"args": [
|
|
83
|
+
"--directory",
|
|
84
|
+
"/path/to/packages/server-python",
|
|
85
|
+
"run",
|
|
86
|
+
"opcua-mcp-server"
|
|
87
|
+
],
|
|
88
|
+
"env": {
|
|
89
|
+
"OPCUA_SERVER_URL": "opc.tcp://localhost:4840"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Example Usage in Conversation
|
|
97
|
+
|
|
98
|
+
Once configured, you can ask Claude to perform real-world OPC UA operations:
|
|
99
|
+
|
|
100
|
+
### Reading Sensor Values
|
|
101
|
+
- "What is the current temperature reading from the reactor vessel?"
|
|
102
|
+
- "Check the pressure level in Tank A"
|
|
103
|
+
- "Read all temperature sensors in the cooling system"
|
|
104
|
+
- "Get the current flow rate on production line B"
|
|
105
|
+
|
|
106
|
+
### Controlling Equipment
|
|
107
|
+
- "Turn on the conveyor motor"
|
|
108
|
+
- "Set the mixer speed to 80 RPM"
|
|
109
|
+
- "Open valve V-101 to 75% position"
|
|
110
|
+
- "Start the circulation pump"
|
|
111
|
+
- "Set the heating element to 150°C"
|
|
112
|
+
|
|
113
|
+
### Production Operations
|
|
114
|
+
- "Start production on line 1 with rate 100 units/hour"
|
|
115
|
+
- "Stop all motors in emergency mode"
|
|
116
|
+
- "Initialize the batch reactor with recipe A"
|
|
117
|
+
- "Begin the cleaning cycle for Tank B"
|
|
118
|
+
|
|
119
|
+
### System Monitoring
|
|
120
|
+
- "Show me all alarm states in the system"
|
|
121
|
+
- "Read all sensor values from the water treatment plant"
|
|
122
|
+
- "Check the status of all pumps and motors"
|
|
123
|
+
- "Get the energy consumption readings"
|
|
124
|
+
- "What variables are available on this OPC UA server?"
|
|
125
|
+
- "Show me all sensors and their current values"
|
|
126
|
+
|
|
127
|
+
### Real Example Scenarios
|
|
128
|
+
```
|
|
129
|
+
User: "What's the temperature in reactor R-101?"
|
|
130
|
+
Assistant: I'll read the temperature sensor for reactor R-101.
|
|
131
|
+
Tool call: read_opcua_node with node_id "ns=2;i=11"
|
|
132
|
+
Result: "Reactor R-101 temperature: 87.3°C"
|
|
133
|
+
|
|
134
|
+
User: "Turn on the main conveyor motor"
|
|
135
|
+
Assistant: I'll start the main conveyor motor for you.
|
|
136
|
+
Tool call: write_opcua_node with node_id "ns=2;i=12" and value "true"
|
|
137
|
+
Result: "Main conveyor motor started successfully"
|
|
138
|
+
|
|
139
|
+
User: "Set the pump speed to 65%"
|
|
140
|
+
Assistant: I'll adjust the pump speed to 65%.
|
|
141
|
+
Tool call: write_opcua_node with node_id "ns=2;i=13" and value "65"
|
|
142
|
+
Result: "Pump speed set to 65%"
|
|
143
|
+
|
|
144
|
+
User: "What variables are available on this OPC UA server?"
|
|
145
|
+
Assistant: I'll retrieve all available variables from the OPC UA server.
|
|
146
|
+
Tool call: get_all_variables
|
|
147
|
+
Result: Found 5 variables:
|
|
148
|
+
- Temperature (ns=2;i=2): 25.3°C - Temperature sensor
|
|
149
|
+
- Pressure (ns=2;i=3): 5.0 bar - Pressure sensor
|
|
150
|
+
- MotorSpeed (ns=2;i=4): 1500 RPM - Motor speed
|
|
151
|
+
- MotorState (ns=2;i=5): True - Motor ON/OFF state
|
|
152
|
+
- ValvePosition (ns=2;i=6): False - Valve OPEN/CLOSED position
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## API Reference
|
|
156
|
+
|
|
157
|
+
See the central per-tool reference in **[../../docs/examples.md](../../docs/examples.md)** for full tool signatures, parameters, and return formats. The shared tool surface is defined in **[../../contract/tools.json](../../contract/tools.json)**.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# OPC UA MCP Server
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server that provides seamless integration with OPC UA servers. This server enables AI assistants and other MCP clients to interact with industrial automation systems through standardized OPC UA communication protocols.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This MCP server acts as a bridge between AI assistants and OPC UA servers, allowing for:
|
|
8
|
+
- Reading sensor data and system variables
|
|
9
|
+
- Writing control values to actuators and systems
|
|
10
|
+
- Browsing OPC UA node hierarchies
|
|
11
|
+
- Calling OPC UA methods for system operations
|
|
12
|
+
- Batch operations for multiple nodes
|
|
13
|
+
|
|
14
|
+
## Tools
|
|
15
|
+
|
|
16
|
+
See the central per-tool reference in **[../../docs/examples.md](../../docs/examples.md)**; the shared tool surface is defined in **[../../contract/tools.json](../../contract/tools.json)**.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
### Key Capabilities
|
|
21
|
+
|
|
22
|
+
- **Automatic Connection Management**: Handles OPC UA client lifecycle with proper connection setup and teardown
|
|
23
|
+
- **Type-Safe Operations**: Automatic type conversion based on existing node data types
|
|
24
|
+
- **Error Handling**: Comprehensive error reporting for debugging and monitoring
|
|
25
|
+
- **Async Support**: Built on FastMCP for efficient asynchronous operations
|
|
26
|
+
- **Configurable**: Environment-based server URL configuration
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
### Prerequisites
|
|
31
|
+
|
|
32
|
+
- Python 3.13 or higher
|
|
33
|
+
- Access to an OPC UA server (local or remote)
|
|
34
|
+
- UV package manager (recommended) or pip
|
|
35
|
+
|
|
36
|
+
### Setup
|
|
37
|
+
|
|
38
|
+
1. **Install dependencies for the whole workspace (run from the repo root):**
|
|
39
|
+
```bash
|
|
40
|
+
uv sync --all-packages
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
2. **Configure the OPC UA server URL:**
|
|
44
|
+
```bash
|
|
45
|
+
export OPCUA_SERVER_URL="opc.tcp://localhost:4840"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Running the Server
|
|
51
|
+
|
|
52
|
+
After `uv sync --all-packages` from the repo root:
|
|
53
|
+
```bash
|
|
54
|
+
uv run --no-sync opcua-mcp-server
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or run it directly against this package from anywhere in the repo:
|
|
58
|
+
```bash
|
|
59
|
+
uv --directory packages/server-python run opcua-mcp-server
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Integration with MCP Clients
|
|
63
|
+
|
|
64
|
+
Add to your MCP client configuration (e.g., `config.json`):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"opcua-mcp": {
|
|
70
|
+
"command": "/path/to/uv",
|
|
71
|
+
"args": [
|
|
72
|
+
"--directory",
|
|
73
|
+
"/path/to/packages/server-python",
|
|
74
|
+
"run",
|
|
75
|
+
"opcua-mcp-server"
|
|
76
|
+
],
|
|
77
|
+
"env": {
|
|
78
|
+
"OPCUA_SERVER_URL": "opc.tcp://localhost:4840"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Example Usage in Conversation
|
|
86
|
+
|
|
87
|
+
Once configured, you can ask Claude to perform real-world OPC UA operations:
|
|
88
|
+
|
|
89
|
+
### Reading Sensor Values
|
|
90
|
+
- "What is the current temperature reading from the reactor vessel?"
|
|
91
|
+
- "Check the pressure level in Tank A"
|
|
92
|
+
- "Read all temperature sensors in the cooling system"
|
|
93
|
+
- "Get the current flow rate on production line B"
|
|
94
|
+
|
|
95
|
+
### Controlling Equipment
|
|
96
|
+
- "Turn on the conveyor motor"
|
|
97
|
+
- "Set the mixer speed to 80 RPM"
|
|
98
|
+
- "Open valve V-101 to 75% position"
|
|
99
|
+
- "Start the circulation pump"
|
|
100
|
+
- "Set the heating element to 150°C"
|
|
101
|
+
|
|
102
|
+
### Production Operations
|
|
103
|
+
- "Start production on line 1 with rate 100 units/hour"
|
|
104
|
+
- "Stop all motors in emergency mode"
|
|
105
|
+
- "Initialize the batch reactor with recipe A"
|
|
106
|
+
- "Begin the cleaning cycle for Tank B"
|
|
107
|
+
|
|
108
|
+
### System Monitoring
|
|
109
|
+
- "Show me all alarm states in the system"
|
|
110
|
+
- "Read all sensor values from the water treatment plant"
|
|
111
|
+
- "Check the status of all pumps and motors"
|
|
112
|
+
- "Get the energy consumption readings"
|
|
113
|
+
- "What variables are available on this OPC UA server?"
|
|
114
|
+
- "Show me all sensors and their current values"
|
|
115
|
+
|
|
116
|
+
### Real Example Scenarios
|
|
117
|
+
```
|
|
118
|
+
User: "What's the temperature in reactor R-101?"
|
|
119
|
+
Assistant: I'll read the temperature sensor for reactor R-101.
|
|
120
|
+
Tool call: read_opcua_node with node_id "ns=2;i=11"
|
|
121
|
+
Result: "Reactor R-101 temperature: 87.3°C"
|
|
122
|
+
|
|
123
|
+
User: "Turn on the main conveyor motor"
|
|
124
|
+
Assistant: I'll start the main conveyor motor for you.
|
|
125
|
+
Tool call: write_opcua_node with node_id "ns=2;i=12" and value "true"
|
|
126
|
+
Result: "Main conveyor motor started successfully"
|
|
127
|
+
|
|
128
|
+
User: "Set the pump speed to 65%"
|
|
129
|
+
Assistant: I'll adjust the pump speed to 65%.
|
|
130
|
+
Tool call: write_opcua_node with node_id "ns=2;i=13" and value "65"
|
|
131
|
+
Result: "Pump speed set to 65%"
|
|
132
|
+
|
|
133
|
+
User: "What variables are available on this OPC UA server?"
|
|
134
|
+
Assistant: I'll retrieve all available variables from the OPC UA server.
|
|
135
|
+
Tool call: get_all_variables
|
|
136
|
+
Result: Found 5 variables:
|
|
137
|
+
- Temperature (ns=2;i=2): 25.3°C - Temperature sensor
|
|
138
|
+
- Pressure (ns=2;i=3): 5.0 bar - Pressure sensor
|
|
139
|
+
- MotorSpeed (ns=2;i=4): 1500 RPM - Motor speed
|
|
140
|
+
- MotorState (ns=2;i=5): True - Motor ON/OFF state
|
|
141
|
+
- ValvePosition (ns=2;i=6): False - Valve OPEN/CLOSED position
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## API Reference
|
|
145
|
+
|
|
146
|
+
See the central per-tool reference in **[../../docs/examples.md](../../docs/examples.md)** for full tool signatures, parameters, and return formats. The shared tool surface is defined in **[../../contract/tools.json](../../contract/tools.json)**.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Single source of truth for the OPC UA MCP tool surface shared by both servers. The Node server builds its tools/list response directly from this file (copied into build/ at build time). The Python (FastMCP) server reads tool descriptions and the capability node IDs from it; its signature-derived input schemas are checked against this file by tests/test_contract_parity.py. To add or change a tool: edit this file, then update each server's per-tool logic (Node: a case in the CallTool switch; Python: the @mcp.tool function), and add an E2E test.",
|
|
3
|
+
"capabilities": {
|
|
4
|
+
"history": {
|
|
5
|
+
"nodeId": "ns=0;i=11193",
|
|
6
|
+
"browseName": "AccessHistoryDataCapability",
|
|
7
|
+
"check": "readBooleanTrue"
|
|
8
|
+
},
|
|
9
|
+
"aggregate": {
|
|
10
|
+
"nodeId": "ns=0;i=2997",
|
|
11
|
+
"browseName": "AggregateFunctions",
|
|
12
|
+
"check": "browseNonEmpty"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"tools": [
|
|
16
|
+
{
|
|
17
|
+
"name": "read_opcua_node",
|
|
18
|
+
"capability": null,
|
|
19
|
+
"description": "Read the value of a specific OPC UA node",
|
|
20
|
+
"inputSchema": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"node_id": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'. Example: 'ns=2;i=2'."
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["node_id"]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "write_opcua_node",
|
|
33
|
+
"capability": null,
|
|
34
|
+
"description": "Write a value to a specific OPC UA node",
|
|
35
|
+
"inputSchema": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"node_id": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'. Example: 'ns=2;i=3'."
|
|
41
|
+
},
|
|
42
|
+
"value": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "The value to write to the node. Will be converted based on node type."
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required": ["node_id", "value"]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "browse_opcua_node_children",
|
|
52
|
+
"capability": null,
|
|
53
|
+
"description": "Browse the children of a specific OPC UA node",
|
|
54
|
+
"inputSchema": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"node_id": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "The OPC UA node ID to browse (e.g., 'ns=0;i=85' for Objects folder)."
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"required": ["node_id"]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "read_multiple_opcua_nodes",
|
|
67
|
+
"capability": null,
|
|
68
|
+
"description": "Read the values of multiple OPC UA nodes in a single request",
|
|
69
|
+
"inputSchema": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"node_ids": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"items": { "type": "string" },
|
|
75
|
+
"description": "A list of OPC UA node IDs to read (e.g., ['ns=2;i=2', 'ns=2;i=3'])."
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"required": ["node_ids"]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "write_multiple_opcua_nodes",
|
|
83
|
+
"capability": null,
|
|
84
|
+
"description": "Write values to multiple OPC UA nodes in a single request",
|
|
85
|
+
"inputSchema": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"properties": {
|
|
88
|
+
"nodes_to_write": {
|
|
89
|
+
"type": "array",
|
|
90
|
+
"items": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"node_id": { "type": "string" },
|
|
94
|
+
"value": { "type": "string" }
|
|
95
|
+
},
|
|
96
|
+
"required": ["node_id", "value"]
|
|
97
|
+
},
|
|
98
|
+
"description": "A list of objects containing 'node_id' and 'value'. Example: [{'node_id': 'ns=2;i=2', 'value': '10.5'}, {'node_id': 'ns=2;i=3', 'value': 'active'}]"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"required": ["nodes_to_write"]
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "call_opcua_method",
|
|
106
|
+
"capability": null,
|
|
107
|
+
"description": "Call a method on a specific OPC UA object node",
|
|
108
|
+
"inputSchema": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"object_node_id": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"description": "The OPC UA node ID of the object that contains the method. Example: 'ns=2;i=1' for the Methods folder."
|
|
114
|
+
},
|
|
115
|
+
"method_node_id": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"description": "The OPC UA node ID of the method to call. Example: 'ns=2;i=2' for StartProduction method."
|
|
118
|
+
},
|
|
119
|
+
"arguments": {
|
|
120
|
+
"type": "array",
|
|
121
|
+
"items": { "type": "string" },
|
|
122
|
+
"description": "List of arguments to pass to the method. Arguments will be converted to appropriate OPC UA variants."
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"required": ["object_node_id", "method_node_id"]
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "get_all_variables",
|
|
130
|
+
"capability": null,
|
|
131
|
+
"description": "Get all available variables from the OPC UA server, excluding those under the built-in 'Server' object",
|
|
132
|
+
"inputSchema": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {},
|
|
135
|
+
"required": []
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "read_history_opcua_node",
|
|
140
|
+
"capability": "history",
|
|
141
|
+
"description": "Read the historical values of a specific OPC UA node",
|
|
142
|
+
"inputSchema": {
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"node_id": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'. Example: 'ns=2;i=2'."
|
|
148
|
+
},
|
|
149
|
+
"start_time": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Beginning of the retrieval"
|
|
152
|
+
},
|
|
153
|
+
"end_time": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "End of the retrieval"
|
|
156
|
+
},
|
|
157
|
+
"num_values": {
|
|
158
|
+
"type": "number",
|
|
159
|
+
"description": "Number of values to read (default: unlimited)"
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"required": ["node_id"]
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "read_aggregate_opcua_node",
|
|
167
|
+
"capability": "aggregate",
|
|
168
|
+
"description": "Calculate the historical aggregates over a defined time range, divided into smaller chunks defined by the `processing_interval` (in milliseconds). The server divides the [`start_time`, `end_time`] domain into these intervals, returning one aggregated value per interval",
|
|
169
|
+
"inputSchema": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {
|
|
172
|
+
"node_id": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"description": "The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'. Example: 'ns=2;i=2'."
|
|
175
|
+
},
|
|
176
|
+
"start_time": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"description": "Beginning of the retrieval"
|
|
179
|
+
},
|
|
180
|
+
"end_time": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"description": "End of the retrieval (defaults to 'now')"
|
|
183
|
+
},
|
|
184
|
+
"aggregate_function": {
|
|
185
|
+
"type": "string",
|
|
186
|
+
"description": "The specific formula"
|
|
187
|
+
},
|
|
188
|
+
"processing_interval": {
|
|
189
|
+
"type": "number",
|
|
190
|
+
"description": "The duration (ms) for each computed value. If set to 0, the server calculates a single aggregate value for the entire range."
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"required": ["node_id", "start_time", "aggregate_function"]
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Build hook that stages the shared tool contract inside the wheel.
|
|
2
|
+
|
|
3
|
+
The canonical contract lives at the repo root (`/contract/tools.json`), outside
|
|
4
|
+
this package. A static `force-include` pointing at `../../` works when building
|
|
5
|
+
from a checkout, but breaks when the wheel is built *from an sdist* — which is
|
|
6
|
+
what `uv build` and `pip install <sdist>` do — because an sdist cannot contain
|
|
7
|
+
files from outside its own root:
|
|
8
|
+
|
|
9
|
+
FileNotFoundError: Forced include not found: .../contract/tools.json
|
|
10
|
+
|
|
11
|
+
So the sdist carries its own copy at `contract/tools.json` (see the sdist
|
|
12
|
+
force-include in pyproject.toml), and this hook injects whichever copy exists
|
|
13
|
+
into the wheel at build time. It adds to `build_data` rather than writing into
|
|
14
|
+
the source tree, so building never leaves artefacts behind.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
22
|
+
|
|
23
|
+
#: Where the wheel expects to find it; `contract.py` reads this path.
|
|
24
|
+
WHEEL_PATH = "opcua_mcp_server/tools.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContractBuildHook(BuildHookInterface):
|
|
28
|
+
PLUGIN_NAME = "opcua-contract"
|
|
29
|
+
|
|
30
|
+
def initialize(self, version: str, build_data: dict) -> None:
|
|
31
|
+
if self.target_name != "wheel":
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
root = Path(self.root)
|
|
35
|
+
candidates = (
|
|
36
|
+
root.parents[1] / "contract" / "tools.json", # repo checkout
|
|
37
|
+
root / "contract" / "tools.json", # unpacked sdist
|
|
38
|
+
)
|
|
39
|
+
for candidate in candidates:
|
|
40
|
+
if candidate.is_file():
|
|
41
|
+
build_data["force_include"][str(candidate)] = WHEEL_PATH
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
raise FileNotFoundError(
|
|
45
|
+
"Shared tool contract not found; looked in " + ", ".join(str(c) for c in candidates)
|
|
46
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opcua-mcp-server"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "MCP server exposing OPC UA read/write/browse/method/history tools (FastMCP + python-opcua)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=45.0.2",
|
|
9
|
+
"httpx>=0.28.1",
|
|
10
|
+
# Upper bound is load-bearing: mcp 2.x renamed FastMCP to MCPServer, so an
|
|
11
|
+
# unbounded install resolves 2.x and crashes on import. Migrating to the 2.x
|
|
12
|
+
# API is tracked separately.
|
|
13
|
+
"mcp[cli]>=1.9.1,<2",
|
|
14
|
+
"opcua>=0.98.13",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
opcua-mcp-server = "opcua_mcp_server:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/opcua_mcp_server"]
|
|
26
|
+
|
|
27
|
+
# The contract is injected into the wheel by hatch_build.py rather than by a
|
|
28
|
+
# static force-include, because the path differs between a checkout and an
|
|
29
|
+
# unpacked sdist. See that file for the full explanation.
|
|
30
|
+
[tool.hatch.build.hooks.custom]
|
|
31
|
+
path = "hatch_build.py"
|
|
32
|
+
|
|
33
|
+
# The sdist must be self-contained: it carries its own copy of the contract (and
|
|
34
|
+
# the build hook) so that building a wheel from it works outside a checkout.
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["src", "hatch_build.py", "pyproject.toml", "README.md"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
39
|
+
"../../contract/tools.json" = "contract/tools.json"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""OPC UA MCP server (Python runtime).
|
|
2
|
+
|
|
3
|
+
Exposes OPC UA read/write/browse/method/history operations as MCP tools. The
|
|
4
|
+
tool surface is defined once in ``contract/tools.json`` and shared with the Node
|
|
5
|
+
runtime; see ``contract.py``.
|
|
6
|
+
|
|
7
|
+
Importing this package connects to the configured OPC UA server once, to probe
|
|
8
|
+
which capability-gated tools to register (see ``capabilities``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .config import SERVER_URL
|
|
14
|
+
from .contract import CONTRACT, DESC, HISTORY_NODE_ID, load_contract
|
|
15
|
+
from .datetimes import parse_iso_datetime
|
|
16
|
+
from .server import main, mcp
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CONTRACT",
|
|
20
|
+
"DESC",
|
|
21
|
+
"HISTORY_NODE_ID",
|
|
22
|
+
"SERVER_URL",
|
|
23
|
+
"load_contract",
|
|
24
|
+
"main",
|
|
25
|
+
"mcp",
|
|
26
|
+
"parse_iso_datetime",
|
|
27
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Mapping between OPC UA aggregate function names and their node IDs.
|
|
2
|
+
|
|
3
|
+
The OPC UA spec defines aggregate functions as well-known nodes in namespace 0
|
|
4
|
+
(``AggregateFunction_Average`` = ``i=2342`` and friends). A server advertises the
|
|
5
|
+
subset it implements by exposing them under ``Server/ServerCapabilities/
|
|
6
|
+
AggregateFunctions``; this module turns the browse names found there back into
|
|
7
|
+
the node IDs a ``ReadProcessedDetails`` request needs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Collection
|
|
13
|
+
|
|
14
|
+
from opcua import ua
|
|
15
|
+
|
|
16
|
+
_PREFIX = "AggregateFunction_"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def known_aggregate_names() -> frozenset[str]:
|
|
20
|
+
"""Every aggregate function name defined by the OPC UA spec."""
|
|
21
|
+
return frozenset(name[len(_PREFIX) :] for name in dir(ua.ObjectIds) if name.startswith(_PREFIX))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def spec_aggregate_node_ids() -> dict[str, ua.NodeId]:
|
|
25
|
+
"""Every spec-defined aggregate function name mapped to its node ID."""
|
|
26
|
+
return {
|
|
27
|
+
name.removeprefix(_PREFIX): ua.NodeId(identifier, 0)
|
|
28
|
+
for name, identifier in vars(ua.ObjectIds).items()
|
|
29
|
+
if name.startswith(_PREFIX)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def aggregate_node_id(name: str) -> ua.NodeId:
|
|
34
|
+
"""Node ID for a spec-defined aggregate function name.
|
|
35
|
+
|
|
36
|
+
Raises ValueError for anything not in the spec, so an unknown name fails
|
|
37
|
+
before a request is put on the wire.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
identifier = getattr(ua.ObjectIds, f"{_PREFIX}{name}")
|
|
41
|
+
except AttributeError:
|
|
42
|
+
raise ValueError(f"Unknown aggregate function: {name}") from None
|
|
43
|
+
return ua.NodeId(identifier, 0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_aggregate_function(name: str, supported: Collection[str]) -> None:
|
|
47
|
+
"""Raise unless ``name`` is one the connected server advertises.
|
|
48
|
+
|
|
49
|
+
The two messages are mirrored verbatim by the Node server, so both runtimes
|
|
50
|
+
reject the same input the same way.
|
|
51
|
+
"""
|
|
52
|
+
if name in supported:
|
|
53
|
+
return
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Server does not advertise any aggregate functions"
|
|
56
|
+
if not supported
|
|
57
|
+
else f"Invalid aggregate function. Supported: {', '.join(supported)}"
|
|
58
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Runtime probes for optional OPC UA server capabilities.
|
|
2
|
+
|
|
3
|
+
Tools that depend on a capability are only registered when the connected
|
|
4
|
+
server actually advertises it, mirroring the Node server's gating.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from opcua import Client, ua
|
|
10
|
+
|
|
11
|
+
from .aggregates import spec_aggregate_node_ids
|
|
12
|
+
from .contract import AGGREGATE_NODE_ID, HISTORY_NODE_ID
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def server_supports_history(url: str) -> bool:
|
|
16
|
+
"""Probe the server's AccessHistoryDataCapability (ns=0;i=11193).
|
|
17
|
+
|
|
18
|
+
Used to expose `read_history_opcua_node` only when the server actually
|
|
19
|
+
supports historical reads, matching the Node server's behaviour.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
probe = Client(url)
|
|
23
|
+
probe.connect()
|
|
24
|
+
try:
|
|
25
|
+
return bool(probe.get_node(HISTORY_NODE_ID).get_value())
|
|
26
|
+
finally:
|
|
27
|
+
probe.disconnect()
|
|
28
|
+
except Exception:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def server_aggregate_functions(url: str) -> dict[str, ua.NodeId]:
|
|
33
|
+
"""The aggregate functions the server advertises, mapped to their node IDs.
|
|
34
|
+
|
|
35
|
+
Browses ``Server/ServerCapabilities/AggregateFunctions`` and keeps only
|
|
36
|
+
children whose browse name *and* node ID match a spec-defined aggregate, so a
|
|
37
|
+
server exposing something unexpected under that folder cannot smuggle in a
|
|
38
|
+
node ID we then send back in a request.
|
|
39
|
+
|
|
40
|
+
Best-effort: any failure yields an empty mapping rather than an error, so a
|
|
41
|
+
transient outage leaves the core tools advertised instead of breaking
|
|
42
|
+
tools/list.
|
|
43
|
+
"""
|
|
44
|
+
spec = spec_aggregate_node_ids()
|
|
45
|
+
try:
|
|
46
|
+
probe = Client(url)
|
|
47
|
+
probe.connect()
|
|
48
|
+
try:
|
|
49
|
+
node = probe.get_node(AGGREGATE_NODE_ID)
|
|
50
|
+
advertised = {}
|
|
51
|
+
for child in node.get_referenced_nodes(
|
|
52
|
+
refs=ua.ObjectIds.References,
|
|
53
|
+
direction=ua.BrowseDirection.Forward,
|
|
54
|
+
):
|
|
55
|
+
try:
|
|
56
|
+
name = child.get_browse_name().Name
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
if name in spec and child.nodeid == spec[name]:
|
|
60
|
+
advertised[name] = child.nodeid
|
|
61
|
+
return advertised
|
|
62
|
+
finally:
|
|
63
|
+
probe.disconnect()
|
|
64
|
+
except Exception:
|
|
65
|
+
return {}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The shared tool contract — the single source of truth both servers derive from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_contract() -> dict:
|
|
10
|
+
"""Load the shared tool contract (single source of truth).
|
|
11
|
+
|
|
12
|
+
Two locations, in order:
|
|
13
|
+
|
|
14
|
+
1. ``tools.json`` inside this package — how it ships in the wheel (see the
|
|
15
|
+
``force-include`` in pyproject.toml). Bundling it *inside* the package
|
|
16
|
+
rather than at the install root keeps the distribution from adding
|
|
17
|
+
top-level files to ``site-packages``.
|
|
18
|
+
2. ``/contract/tools.json`` at the repo root — the canonical source, used when
|
|
19
|
+
running from a checkout (dev, editable installs, tests).
|
|
20
|
+
|
|
21
|
+
Without (1) a pip/uvx install would raise FileNotFoundError on import, because
|
|
22
|
+
the repo-root path does not exist outside a checkout.
|
|
23
|
+
"""
|
|
24
|
+
here = Path(__file__).resolve()
|
|
25
|
+
candidates = (
|
|
26
|
+
here.parent / "tools.json", # bundled inside the wheel
|
|
27
|
+
here.parents[4] / "contract" / "tools.json", # repo-root source layout
|
|
28
|
+
)
|
|
29
|
+
for path in candidates:
|
|
30
|
+
if path.is_file():
|
|
31
|
+
return json.loads(path.read_text())
|
|
32
|
+
raise FileNotFoundError(
|
|
33
|
+
"Shared tool contract not found; looked in " + ", ".join(str(p) for p in candidates)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Shared tool contract so tool descriptions and capability node IDs stay in sync
|
|
38
|
+
# with the Node server.
|
|
39
|
+
CONTRACT = load_contract()
|
|
40
|
+
DESC = {t["name"]: t["description"] for t in CONTRACT["tools"]}
|
|
41
|
+
HISTORY_NODE_ID = CONTRACT["capabilities"]["history"]["nodeId"]
|
|
42
|
+
AGGREGATE_NODE_ID = CONTRACT["capabilities"]["aggregate"]["nodeId"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Conversion between the ISO-8601 strings MCP delivers and datetimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_iso_datetime(value: str | None) -> datetime | None:
|
|
9
|
+
"""Parse an optional ISO-8601 string into a datetime.
|
|
10
|
+
|
|
11
|
+
MCP delivers these as strings, so they are converted here before being handed
|
|
12
|
+
to the opcua client. Mirrors the Node server's ``toDate`` error wording so both
|
|
13
|
+
servers reject malformed input identically.
|
|
14
|
+
"""
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
19
|
+
except (ValueError, TypeError):
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f'Invalid date/time: "{value}". Use ISO 8601, e.g. 2026-04-23T17:40:00Z'
|
|
22
|
+
) from None
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""The MCP server: lifecycle, tool registration, and the stdio entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
14
|
+
from opcua import Client, ua
|
|
15
|
+
from opcua.ua import NodeClass
|
|
16
|
+
|
|
17
|
+
from .aggregates import validate_aggregate_function
|
|
18
|
+
from .capabilities import server_aggregate_functions, server_supports_history
|
|
19
|
+
from .config import SERVER_URL
|
|
20
|
+
from .contract import DESC
|
|
21
|
+
from .datetimes import parse_iso_datetime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Manage the lifecycle of the OPC UA client connection
|
|
25
|
+
@asynccontextmanager
|
|
26
|
+
async def opcua_lifespan(server: FastMCP) -> AsyncIterator[dict]:
|
|
27
|
+
"""Handle OPC UA client connection lifecycle."""
|
|
28
|
+
client = Client(SERVER_URL)
|
|
29
|
+
try:
|
|
30
|
+
# Connect to OPC UA server synchronously, wrapped in a thread for async compatibility
|
|
31
|
+
await asyncio.to_thread(client.connect)
|
|
32
|
+
# Log to stderr: stdout is reserved for the MCP stdio JSON-RPC transport.
|
|
33
|
+
print("Connected to OPC UA server", file=sys.stderr)
|
|
34
|
+
yield {"opcua_client": client}
|
|
35
|
+
finally:
|
|
36
|
+
# Disconnect from OPC UA server on shutdown
|
|
37
|
+
await asyncio.to_thread(client.disconnect)
|
|
38
|
+
print("Disconnected from OPC UA server", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _package_version() -> str:
|
|
42
|
+
"""Version of the installed distribution, single-sourced from pyproject.toml.
|
|
43
|
+
|
|
44
|
+
Falls back to "0.0.0+unknown" when running from a source tree that was never
|
|
45
|
+
installed (the distribution metadata is absent), so importing never fails.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return version("opcua-mcp-server")
|
|
49
|
+
except PackageNotFoundError:
|
|
50
|
+
return "0.0.0+unknown"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Create an MCP server instance. The server identity must match the Node server's
|
|
54
|
+
# so both runtimes present themselves as the same product to MCP clients.
|
|
55
|
+
mcp = FastMCP("opcua-mcp-server", lifespan=opcua_lifespan)
|
|
56
|
+
# FastMCP does not expose the protocol-level version in its constructor, so set it
|
|
57
|
+
# on the underlying low-level server. Without this the Python server reports a
|
|
58
|
+
# null version over MCP while the Node server reports a real one.
|
|
59
|
+
mcp._mcp_server.version = _package_version()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Tool: Read the value of an OPC UA node
|
|
63
|
+
@mcp.tool(description=DESC["read_opcua_node"])
|
|
64
|
+
def read_opcua_node(node_id: str, ctx: Context) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Read the value of a specific OPC UA node.
|
|
67
|
+
|
|
68
|
+
Parameters:
|
|
69
|
+
node_id (str): The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'.
|
|
70
|
+
Example: 'ns=2;i=2'.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
str: The value of the node as a string, prefixed with the node ID.
|
|
74
|
+
"""
|
|
75
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
76
|
+
node = client.get_node(node_id)
|
|
77
|
+
value = node.get_value() # Synchronous call to get node value
|
|
78
|
+
return f"Node {node_id} value: {value}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Tool: Read historical values of an OPC UA node.
|
|
82
|
+
# Registered only when the server supports historical data access (see below),
|
|
83
|
+
# mirroring the Node server's capability gating.
|
|
84
|
+
def read_history_opcua_node(
|
|
85
|
+
node_id: str,
|
|
86
|
+
ctx: Context,
|
|
87
|
+
start_time: str | None = None,
|
|
88
|
+
end_time: str | None = None,
|
|
89
|
+
num_values: int = 0,
|
|
90
|
+
) -> list[dict]:
|
|
91
|
+
"""
|
|
92
|
+
Read the historical values of a specific OPC UA node.
|
|
93
|
+
|
|
94
|
+
Parameters:
|
|
95
|
+
node_id (str): The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'.
|
|
96
|
+
Example: 'ns=2;i=2'.
|
|
97
|
+
start_time (str): Start time (ISO 8601).
|
|
98
|
+
Example: '2026-04-22T18:50:00Z'
|
|
99
|
+
end_time (str): End time (ISO 8601).
|
|
100
|
+
Example: '2026-04-22T18:51:00Z'
|
|
101
|
+
num_values (int): Number of values to read (default: unlimited)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
list[dict]: An array of values shaped
|
|
105
|
+
`{ "value": <value>, "timestamp": <timestamp>, "status": "Good" }`
|
|
106
|
+
"""
|
|
107
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
108
|
+
node = client.get_node(node_id)
|
|
109
|
+
values = node.read_raw_history(
|
|
110
|
+
starttime=parse_iso_datetime(start_time),
|
|
111
|
+
endtime=parse_iso_datetime(end_time),
|
|
112
|
+
numvalues=num_values,
|
|
113
|
+
)
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
"value": str(v.Value.Value),
|
|
117
|
+
"timestamp": str(v.SourceTimestamp),
|
|
118
|
+
"status": str(v.StatusCode.name),
|
|
119
|
+
}
|
|
120
|
+
for v in values
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Conditionally register the history tool based on server capability.
|
|
125
|
+
if server_supports_history(SERVER_URL):
|
|
126
|
+
read_history_opcua_node = mcp.tool(description=DESC["read_history_opcua_node"])(
|
|
127
|
+
read_history_opcua_node
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Tool: Read server-computed aggregates over a node's history.
|
|
132
|
+
# Registered only when the server advertises aggregate functions, mirroring the
|
|
133
|
+
# Node server's capability gating.
|
|
134
|
+
_AGGREGATE_FUNCTIONS = server_aggregate_functions(SERVER_URL)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_aggregate_opcua_node(
|
|
138
|
+
node_id: str,
|
|
139
|
+
ctx: Context,
|
|
140
|
+
start_time: str,
|
|
141
|
+
aggregate_function: str,
|
|
142
|
+
end_time: str | None = None,
|
|
143
|
+
processing_interval: float = 0,
|
|
144
|
+
) -> list[dict]:
|
|
145
|
+
"""
|
|
146
|
+
Calculate historical aggregates over a time range, in fixed-size intervals.
|
|
147
|
+
|
|
148
|
+
Parameters:
|
|
149
|
+
node_id (str): The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'.
|
|
150
|
+
Example: 'ns=2;i=2'.
|
|
151
|
+
start_time (str): Beginning of the retrieval (ISO 8601).
|
|
152
|
+
aggregate_function (str): The specific formula, e.g. 'Average'.
|
|
153
|
+
end_time (str): End of the retrieval (ISO 8601, defaults to 'now').
|
|
154
|
+
processing_interval (float): Duration (ms) for each computed value. 0 asks
|
|
155
|
+
the server for a single value over the range.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
list[dict]: One entry per interval, shaped
|
|
159
|
+
`{ "value": <value>, "timestamp": <timestamp>, "status": <status> }`
|
|
160
|
+
"""
|
|
161
|
+
# Re-probe rather than trusting the import-time snapshot: a server may gain or
|
|
162
|
+
# lose aggregate support while this process is running, and answering from a
|
|
163
|
+
# stale cache would report the wrong supported set.
|
|
164
|
+
aggregate_functions = server_aggregate_functions(SERVER_URL)
|
|
165
|
+
validate_aggregate_function(aggregate_function, aggregate_functions)
|
|
166
|
+
|
|
167
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
168
|
+
try:
|
|
169
|
+
details = ua.ReadProcessedDetails()
|
|
170
|
+
details.StartTime = parse_iso_datetime(start_time)
|
|
171
|
+
# UTC, not naive local time: `parse_iso_datetime` yields aware UTC, so a
|
|
172
|
+
# naive `datetime.now()` here would shift the window end by the host's UTC
|
|
173
|
+
# offset and pad the result with an empty bucket per interval in between.
|
|
174
|
+
details.EndTime = parse_iso_datetime(end_time) or datetime.now(timezone.utc)
|
|
175
|
+
details.ProcessingInterval = processing_interval
|
|
176
|
+
details.AggregateType = [aggregate_functions[aggregate_function]]
|
|
177
|
+
|
|
178
|
+
result = client.get_node(node_id).history_read(details)
|
|
179
|
+
if not result.StatusCode.is_good():
|
|
180
|
+
raise ValueError(f"Read aggregate failed with status: {result.StatusCode}")
|
|
181
|
+
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
"value": str(v.Value.Value),
|
|
185
|
+
"timestamp": str(v.SourceTimestamp),
|
|
186
|
+
"status": str(v.StatusCode.name),
|
|
187
|
+
}
|
|
188
|
+
for v in result.HistoryData.DataValues
|
|
189
|
+
]
|
|
190
|
+
except Exception as e:
|
|
191
|
+
raise ValueError(f"Failed to read node {node_id}: {e!s}") from e
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if _AGGREGATE_FUNCTIONS:
|
|
195
|
+
read_aggregate_opcua_node = mcp.tool(description=DESC["read_aggregate_opcua_node"])(
|
|
196
|
+
read_aggregate_opcua_node
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Tool: Write a value to an OPC UA node
|
|
201
|
+
@mcp.tool(description=DESC["write_opcua_node"])
|
|
202
|
+
def write_opcua_node(node_id: str, value: str, ctx: Context) -> str:
|
|
203
|
+
"""
|
|
204
|
+
Write a value to a specific OPC UA node.
|
|
205
|
+
|
|
206
|
+
Parameters:
|
|
207
|
+
node_id (str): The OPC UA node ID in the format 'ns=<namespace>;i=<identifier>'.
|
|
208
|
+
Example: 'ns=2;i=3'.
|
|
209
|
+
value (str): The value to write to the node. Will be converted based on node type.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
str: A message indicating success or failure of the write operation.
|
|
213
|
+
"""
|
|
214
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
215
|
+
node = client.get_node(node_id)
|
|
216
|
+
try:
|
|
217
|
+
# Convert value based on the node's current type.
|
|
218
|
+
# Note: check bool before (int, float) because bool is a subclass of int.
|
|
219
|
+
current_value = node.get_value()
|
|
220
|
+
if isinstance(current_value, bool):
|
|
221
|
+
node.set_value(str(value).lower() in ["true", "1", "yes", "on"])
|
|
222
|
+
elif isinstance(current_value, (int, float)):
|
|
223
|
+
node.set_value(float(value))
|
|
224
|
+
else:
|
|
225
|
+
node.set_value(value)
|
|
226
|
+
return f"Successfully wrote {value} to node {node_id}"
|
|
227
|
+
except Exception as e:
|
|
228
|
+
return f"Error writing to node {node_id}: {e!s}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Tool: Browse the children of a specific OPC UA node
|
|
232
|
+
@mcp.tool(description=DESC["browse_opcua_node_children"])
|
|
233
|
+
def browse_opcua_node_children(node_id: str, ctx: Context) -> str:
|
|
234
|
+
"""
|
|
235
|
+
Browse the children of a specific OPC UA node.
|
|
236
|
+
|
|
237
|
+
Parameters:
|
|
238
|
+
node_id (str): The OPC UA node ID to browse (e.g., 'ns=0;i=85' for Objects folder).
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
str: A string representation of a list of child nodes, including their
|
|
242
|
+
NodeId and BrowseName.
|
|
243
|
+
Returns an error message on failure.
|
|
244
|
+
"""
|
|
245
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
246
|
+
try:
|
|
247
|
+
node = client.get_node(node_id)
|
|
248
|
+
children = node.get_children()
|
|
249
|
+
|
|
250
|
+
children_info = []
|
|
251
|
+
for child in children:
|
|
252
|
+
try:
|
|
253
|
+
browse_name = child.get_browse_name()
|
|
254
|
+
children_info.append(
|
|
255
|
+
{
|
|
256
|
+
"node_id": child.nodeid.to_string(),
|
|
257
|
+
"browse_name": f"{browse_name.NamespaceIndex}:{browse_name.Name}",
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
children_info.append(
|
|
262
|
+
{"node_id": child.nodeid.to_string(), "browse_name": f"Error getting name: {e}"}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# import json
|
|
266
|
+
# return json.dumps(children_info, indent=2)
|
|
267
|
+
return f"Children of {node_id}: {children_info!r}"
|
|
268
|
+
|
|
269
|
+
except Exception as e:
|
|
270
|
+
return f"Error Browse children of node {node_id}: {e!s}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Tool: Call an OPC UA method
|
|
274
|
+
@mcp.tool(description=DESC["call_opcua_method"])
|
|
275
|
+
def call_opcua_method(
|
|
276
|
+
object_node_id: str, method_node_id: str, ctx: Context, arguments: list[Any] | None = None
|
|
277
|
+
) -> str:
|
|
278
|
+
"""
|
|
279
|
+
Call a method on a specific OPC UA object node.
|
|
280
|
+
|
|
281
|
+
Parameters:
|
|
282
|
+
object_node_id (str): The OPC UA node ID of the object that contains the method.
|
|
283
|
+
Example: 'ns=2;i=1' for the Methods folder.
|
|
284
|
+
method_node_id (str): The OPC UA node ID of the method to call.
|
|
285
|
+
Example: 'ns=2;i=2' for StartProduction method.
|
|
286
|
+
ctx (Context): The context for the request.
|
|
287
|
+
arguments (List[Any], optional): List of arguments to pass to the method.
|
|
288
|
+
Arguments will be converted to appropriate OPC UA variants.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
str: The result of the method call or an error message if the call fails.
|
|
292
|
+
"""
|
|
293
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
294
|
+
try:
|
|
295
|
+
# Get the object and method nodes
|
|
296
|
+
object_node = client.get_node(object_node_id)
|
|
297
|
+
method_node = client.get_node(method_node_id)
|
|
298
|
+
|
|
299
|
+
# Prepare arguments
|
|
300
|
+
method_args = []
|
|
301
|
+
if arguments:
|
|
302
|
+
for arg in arguments:
|
|
303
|
+
# Convert arguments to appropriate types
|
|
304
|
+
if isinstance(arg, str):
|
|
305
|
+
# Try to convert string to appropriate type
|
|
306
|
+
try:
|
|
307
|
+
# Try float first
|
|
308
|
+
method_args.append(float(arg))
|
|
309
|
+
except ValueError:
|
|
310
|
+
try:
|
|
311
|
+
# Try int
|
|
312
|
+
method_args.append(int(arg))
|
|
313
|
+
except ValueError:
|
|
314
|
+
# Keep as string
|
|
315
|
+
method_args.append(arg)
|
|
316
|
+
else:
|
|
317
|
+
method_args.append(arg)
|
|
318
|
+
|
|
319
|
+
# Call the method on the object node. python-opcua exposes call_method on Node
|
|
320
|
+
# (not Client), and a string methodid is treated as a child browse-name, so pass
|
|
321
|
+
# the resolved method Node to call it by node id.
|
|
322
|
+
result = object_node.call_method(method_node, *method_args)
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
f"Method call successful. Object: {object_node_id}, "
|
|
326
|
+
f"Method: {method_node_id}, Result: {result}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
except Exception as e:
|
|
330
|
+
return f"Error calling method {method_node_id} on object {object_node_id}: {e!s}"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# Tool: Read multiple OPC UA nodes
|
|
334
|
+
@mcp.tool(description=DESC["read_multiple_opcua_nodes"])
|
|
335
|
+
def read_multiple_opcua_nodes(node_ids: list[str], ctx: Context) -> str:
|
|
336
|
+
"""
|
|
337
|
+
Read the values of multiple OPC UA nodes in a single request.
|
|
338
|
+
|
|
339
|
+
Parameters:
|
|
340
|
+
node_ids (List[str]): A list of OPC UA node IDs to read (e.g., ['ns=2;i=2', 'ns=2;i=3']).
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
str: A string representation of a dictionary mapping node IDs to their
|
|
344
|
+
values, or an error message.
|
|
345
|
+
"""
|
|
346
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
347
|
+
try:
|
|
348
|
+
results = {}
|
|
349
|
+
for node_id in node_ids:
|
|
350
|
+
try:
|
|
351
|
+
node = client.get_node(node_id)
|
|
352
|
+
value = node.get_value()
|
|
353
|
+
results[node_id] = value
|
|
354
|
+
except Exception as e:
|
|
355
|
+
results[node_id] = f"Error: {e!s}"
|
|
356
|
+
|
|
357
|
+
return f"Multiple node read results: {results!r}"
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
return f"Error reading multiple nodes: {e!s}"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# Tool: Write multiple OPC UA nodes
|
|
364
|
+
@mcp.tool(description=DESC["write_multiple_opcua_nodes"])
|
|
365
|
+
def write_multiple_opcua_nodes(nodes_to_write: list[dict[str, Any]], ctx: Context) -> str:
|
|
366
|
+
"""
|
|
367
|
+
Write values to multiple OPC UA nodes in a single request.
|
|
368
|
+
|
|
369
|
+
Parameters:
|
|
370
|
+
nodes_to_write (List[Dict[str, Any]]): A list of dictionaries, where each dictionary
|
|
371
|
+
contains 'node_id' (str) and 'value' (Any).
|
|
372
|
+
The value will be wrapped in an OPC UA Variant.
|
|
373
|
+
Example: [{'node_id': 'ns=2;i=2', 'value': 10.5},
|
|
374
|
+
{'node_id': 'ns=2;i=3', 'value': 'active'}]
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
str: A message indicating the success or failure of the write operation.
|
|
378
|
+
Returns status codes for each write attempt.
|
|
379
|
+
"""
|
|
380
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
381
|
+
try:
|
|
382
|
+
results = []
|
|
383
|
+
for item in nodes_to_write:
|
|
384
|
+
node_id = item["node_id"]
|
|
385
|
+
value = item["value"]
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
node = client.get_node(node_id)
|
|
389
|
+
|
|
390
|
+
# Convert value based on the node's current type.
|
|
391
|
+
# Note: check bool before (int, float) because bool is a subclass of int.
|
|
392
|
+
current_value = node.get_value()
|
|
393
|
+
if isinstance(current_value, bool):
|
|
394
|
+
converted_value = str(value).lower() in ["true", "1", "yes", "on"]
|
|
395
|
+
elif isinstance(current_value, (int, float)):
|
|
396
|
+
converted_value = float(value)
|
|
397
|
+
else:
|
|
398
|
+
converted_value = str(value)
|
|
399
|
+
|
|
400
|
+
node.set_value(converted_value)
|
|
401
|
+
results.append({"node_id": node_id, "status": "Success"})
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
results.append({"node_id": node_id, "status": f"Error: {e!s}"})
|
|
405
|
+
|
|
406
|
+
return f"Write operation results: {results!r}"
|
|
407
|
+
|
|
408
|
+
except Exception as e:
|
|
409
|
+
return f"Error writing multiple nodes: {e!s}"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Tool: Get all variables information
|
|
413
|
+
@mcp.tool(description=DESC["get_all_variables"])
|
|
414
|
+
def get_all_variables(ctx: Context) -> str:
|
|
415
|
+
"""
|
|
416
|
+
Get all available variables from the OPC UA server, excluding those under
|
|
417
|
+
the built-in 'Server' object.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
str: A string representation of all variables with their name, nodeid, object_id, value,
|
|
421
|
+
data_type, and description.
|
|
422
|
+
"""
|
|
423
|
+
client = ctx.request_context.lifespan_context["opcua_client"]
|
|
424
|
+
variables_info = []
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
objects_node = client.get_objects_node()
|
|
428
|
+
|
|
429
|
+
def search_variables(node):
|
|
430
|
+
try:
|
|
431
|
+
children = node.get_children()
|
|
432
|
+
except Exception:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
for child in children:
|
|
436
|
+
try:
|
|
437
|
+
node_class = child.get_node_class()
|
|
438
|
+
except Exception:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
# Skip the entire "Server" subtree
|
|
442
|
+
try:
|
|
443
|
+
child_browse_name = child.get_browse_name().Name
|
|
444
|
+
if child_browse_name == "Server":
|
|
445
|
+
continue
|
|
446
|
+
except Exception:
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
if node_class == NodeClass.Variable:
|
|
450
|
+
browse_name = child_browse_name
|
|
451
|
+
node_id = child.nodeid.to_string()
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
parent_node = child.get_parent()
|
|
455
|
+
object_id = parent_node.nodeid.to_string() if parent_node else "N/A"
|
|
456
|
+
except Exception:
|
|
457
|
+
object_id = "N/A"
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
value = child.get_value()
|
|
461
|
+
except Exception:
|
|
462
|
+
value = None
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
data_type = child.get_data_type().to_string()
|
|
466
|
+
except Exception:
|
|
467
|
+
data_type = ""
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
desc = child.get_description().Text
|
|
471
|
+
except Exception:
|
|
472
|
+
desc = ""
|
|
473
|
+
|
|
474
|
+
variables_info.append(
|
|
475
|
+
{
|
|
476
|
+
"name": browse_name,
|
|
477
|
+
"nodeid": node_id,
|
|
478
|
+
"object_id": object_id,
|
|
479
|
+
"value": value,
|
|
480
|
+
"data_type": data_type,
|
|
481
|
+
"description": desc,
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
elif node_class == NodeClass.Object:
|
|
485
|
+
# Recursively search children of this object,
|
|
486
|
+
# unless it is the "Server" object
|
|
487
|
+
search_variables(child)
|
|
488
|
+
|
|
489
|
+
search_variables(objects_node)
|
|
490
|
+
|
|
491
|
+
if variables_info:
|
|
492
|
+
result = f"Found {len(variables_info)} variables:\n"
|
|
493
|
+
for var in variables_info:
|
|
494
|
+
result += f"\n- Name: {var['name']}\n"
|
|
495
|
+
result += f" NodeID: {var['nodeid']}\n"
|
|
496
|
+
result += f" Object ID: {var['object_id']}\n"
|
|
497
|
+
result += f" Value: {var['value']}\n"
|
|
498
|
+
result += f" Data Type: {var['data_type']}\n"
|
|
499
|
+
result += f" Description: {var['description']}\n"
|
|
500
|
+
return result
|
|
501
|
+
else:
|
|
502
|
+
return "No variables found in the OPC UA server."
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
return f"Error while finding variables: {e!s}"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# Run the server
|
|
509
|
+
def main() -> None:
|
|
510
|
+
"""Entry point for the `opcua-mcp-server` console script."""
|
|
511
|
+
mcp.run(transport="stdio")
|