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.
@@ -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,8 @@
1
+ """Runtime configuration, read from the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ #: OPC UA endpoint both the server and the capability probes connect to.
8
+ SERVER_URL = os.getenv("OPCUA_SERVER_URL", "opc.tcp://localhost:4840")
@@ -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")