@codesys/mcp-toolkit 1.5.8 → 2.0.0
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.
- package/CHANGELOG.md +153 -189
- package/LICENSE +21 -21
- package/README.md +269 -152
- package/dist/actions.d.ts +28 -0
- package/dist/actions.js +98 -0
- package/dist/actions.js.map +1 -0
- package/dist/bridge/bridge.d.ts +105 -0
- package/dist/bridge/bridge.js +400 -0
- package/dist/bridge/bridge.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +196 -0
- package/dist/cli.js.map +1 -0
- package/dist/codesys/cache.d.ts +48 -0
- package/dist/codesys/cache.js +82 -0
- package/dist/codesys/cache.js.map +1 -0
- package/dist/codesys/changes.d.ts +57 -0
- package/dist/codesys/changes.js +133 -0
- package/dist/codesys/changes.js.map +1 -0
- package/dist/codesys/decl.d.ts +45 -0
- package/dist/codesys/decl.js +251 -0
- package/dist/codesys/decl.js.map +1 -0
- package/dist/codesys/devices.d.ts +34 -0
- package/dist/codesys/devices.js +82 -0
- package/dist/codesys/devices.js.map +1 -0
- package/dist/codesys/diff.d.ts +13 -0
- package/dist/codesys/diff.js +61 -0
- package/dist/codesys/diff.js.map +1 -0
- package/dist/codesys/install.d.ts +40 -0
- package/dist/codesys/install.js +144 -0
- package/dist/codesys/install.js.map +1 -0
- package/dist/codesys/library-browse.d.ts +19 -0
- package/dist/codesys/library-browse.js +57 -0
- package/dist/codesys/library-browse.js.map +1 -0
- package/dist/codesys/outline.d.ts +32 -0
- package/dist/codesys/outline.js +219 -0
- package/dist/codesys/outline.js.map +1 -0
- package/dist/codesys/simulation.d.ts +34 -0
- package/dist/codesys/simulation.js +57 -0
- package/dist/codesys/simulation.js.map +1 -0
- package/dist/codesys/st.d.ts +36 -0
- package/dist/codesys/st.js +69 -0
- package/dist/codesys/st.js.map +1 -0
- package/dist/daemon.d.ts +46 -0
- package/dist/daemon.js +173 -0
- package/dist/daemon.js.map +1 -0
- package/dist/driver.d.ts +25 -0
- package/dist/driver.js +33 -0
- package/dist/driver.js.map +1 -0
- package/dist/panel/sim-panel.html +357 -0
- package/dist/panel/viewer.html +126 -0
- package/dist/panel-types.d.ts +20 -0
- package/dist/panel-types.js +3 -0
- package/dist/panel-types.js.map +1 -0
- package/dist/panel.d.ts +27 -0
- package/dist/panel.js +208 -0
- package/dist/panel.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +33 -974
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +103 -0
- package/dist/tools.js +793 -0
- package/dist/tools.js.map +1 -0
- package/dist/viewer.d.ts +14 -0
- package/dist/viewer.js +125 -0
- package/dist/viewer.js.map +1 -0
- package/package.json +66 -51
- package/watcher/mcp_compat.py +147 -0
- package/watcher/mcp_ops.py +1178 -0
- package/watcher/mcp_sim.py +246 -0
- package/watcher/watcher.py +255 -0
- package/.claude/settings.local.json +0 -29
- package/CLAUDE.md +0 -26
- package/CONTRIBUTING.md +0 -72
- package/README.md.enhanced +0 -155
- package/codesys-mcp-toolkit-1.2.0.tgz +0 -0
- package/codesys-mcp-toolkit-1.2.1.tgz +0 -0
- package/codesys-mcp-toolkit-1.5.7.tgz +0 -0
- package/codesys-mcp-toolkit-1.5.8.tgz +0 -0
- package/dist/bin.js +0 -75
- package/dist/check_session_paths.js +0 -149
- package/dist/codesys_interop.js +0 -435
- package/dist/direct_session_command.js +0 -293
- package/dist/scriptLoader.js +0 -165
- package/dist/scripts/check_status.py +0 -21
- package/dist/scripts/compile_project.py +0 -65
- package/dist/scripts/create_method.py +0 -80
- package/dist/scripts/create_pou.py +0 -102
- package/dist/scripts/create_project.py +0 -116
- package/dist/scripts/create_property.py +0 -78
- package/dist/scripts/ensure_project_open.py +0 -154
- package/dist/scripts/find_object_by_path.py +0 -111
- package/dist/scripts/get_pou_code.py +0 -91
- package/dist/scripts/get_project_structure.py +0 -72
- package/dist/scripts/open_project.py +0 -25
- package/dist/scripts/save_project.py +0 -29
- package/dist/scripts/session_status.py +0 -281
- package/dist/scripts/set_pou_code.py +0 -109
- package/dist/scripts/start_session.py +0 -809
- package/dist/scripts/stop_session.py +0 -324
- package/dist/sessionManager.js +0 -798
- package/docs/configuration.md +0 -141
- package/docs/index.md +0 -26
- package/docs/installation.md +0 -84
- package/docs/session_management.md +0 -110
- package/examples/README.md +0 -78
- package/examples/sample_config.json +0 -32
- package/replacements_session_status.txt +0 -16
- package/scriptDoc.txt +0 -8679
- package/scripts/build.js +0 -25
- package/scripts/fix_fstrings.py +0 -76
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# CODESYS's own simulation: runs the active application inside CODESYS, with no
|
|
3
|
+
# controller involved. Runs inside CODESYS (IronPython 2.7) and under CPython 3
|
|
4
|
+
# with a fake scriptengine in tests. Keep this file ASCII-only.
|
|
5
|
+
#
|
|
6
|
+
# Every online call first checks that the device is in simulation mode, so these
|
|
7
|
+
# operations can't reach a controller. While a simulation runs the project is
|
|
8
|
+
# locked, so the simulation flag on the device is never saved into it.
|
|
9
|
+
from __future__ import print_function
|
|
10
|
+
|
|
11
|
+
import scriptengine as se
|
|
12
|
+
|
|
13
|
+
import mcp_ops
|
|
14
|
+
from mcp_compat import to_text
|
|
15
|
+
from mcp_ops import Ctx, OpError, _safe
|
|
16
|
+
|
|
17
|
+
SIM = {"online": None, "device": None, "project": None, "was_dirty": False, "forced": set(), "cycles": 0}
|
|
18
|
+
|
|
19
|
+
# Operations that change the project, or could save the simulation flag into it.
|
|
20
|
+
LOCKED = (
|
|
21
|
+
"project.open", "project.open_new", "project.create_empty", "project.save", "project.close",
|
|
22
|
+
"object.write", "object.create", "object.delete", "object.rename", "object.import_xml",
|
|
23
|
+
"libraries.add", "libraries.remove", "task.configure", "build",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Between single cycles: the command returns before the cycle has run.
|
|
27
|
+
CYCLE_PAUSE_MS = 100
|
|
28
|
+
MAX_CYCLES = 1000
|
|
29
|
+
# Long enough for CODESYS to refresh a value it monitors (see _read).
|
|
30
|
+
READ_SETTLE_MS = 150
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_simulation():
|
|
34
|
+
device = SIM["device"]
|
|
35
|
+
if device is None or not device.get_simulation_mode():
|
|
36
|
+
raise OpError("not_simulating", "The device is not in simulation mode, so the toolkit won't go online.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _online():
|
|
40
|
+
if SIM["online"] is None:
|
|
41
|
+
raise OpError("not_running", "The CODESYS simulation isn't running. Call codesys_sim_start first.")
|
|
42
|
+
_require_simulation()
|
|
43
|
+
return SIM["online"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pause(ms):
|
|
47
|
+
if not hasattr(se, "FAKE"):
|
|
48
|
+
se.system.delay(ms)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _device_of(app):
|
|
52
|
+
"""The outermost device above the application."""
|
|
53
|
+
found = None
|
|
54
|
+
obj = app
|
|
55
|
+
while obj is not None:
|
|
56
|
+
if _safe(lambda: obj.is_device, False):
|
|
57
|
+
found = obj
|
|
58
|
+
obj = _safe(lambda: obj.parent)
|
|
59
|
+
if found is None:
|
|
60
|
+
raise OpError("no_device", "No device found above the application.")
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _status():
|
|
65
|
+
online = SIM["online"]
|
|
66
|
+
if online is None:
|
|
67
|
+
return {"running": False}
|
|
68
|
+
return {
|
|
69
|
+
"running": True,
|
|
70
|
+
"state": to_text(online.application_state),
|
|
71
|
+
"operation": to_text(online.operation_state),
|
|
72
|
+
"forced": sorted(SIM["forced"]),
|
|
73
|
+
"cycles": SIM["cycles"],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _release():
|
|
78
|
+
"""Take the device out of simulation mode and leave the project as it was."""
|
|
79
|
+
device, proj, was_dirty = SIM["device"], SIM["project"], SIM["was_dirty"]
|
|
80
|
+
SIM.update({"online": None, "device": None, "project": None, "was_dirty": False, "forced": set(), "cycles": 0})
|
|
81
|
+
if device is not None:
|
|
82
|
+
device.set_simulation_mode(False)
|
|
83
|
+
if proj is not None and not was_dirty:
|
|
84
|
+
proj.save() # same content as before; leaves no unsaved change behind
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _stop():
|
|
88
|
+
online = SIM["online"]
|
|
89
|
+
try:
|
|
90
|
+
_safe(lambda: online.unforce_all_values())
|
|
91
|
+
online.logout()
|
|
92
|
+
finally:
|
|
93
|
+
_release()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def op_sim_start(args):
|
|
97
|
+
c = Ctx()
|
|
98
|
+
if c.app is None:
|
|
99
|
+
raise OpError("no_application", "The project has no active application to simulate.")
|
|
100
|
+
if SIM["online"] is not None:
|
|
101
|
+
_stop()
|
|
102
|
+
SIM["was_dirty"] = bool(_safe(lambda: c.proj.dirty, False))
|
|
103
|
+
SIM["project"] = c.proj
|
|
104
|
+
SIM["device"] = _device_of(c.app)
|
|
105
|
+
try:
|
|
106
|
+
SIM["device"].set_simulation_mode(True)
|
|
107
|
+
_require_simulation()
|
|
108
|
+
online = se.online.create_online_application(c.app)
|
|
109
|
+
online.login(se.OnlineChangeOption.Never, True)
|
|
110
|
+
except BaseException:
|
|
111
|
+
_release()
|
|
112
|
+
raise
|
|
113
|
+
SIM["online"] = online
|
|
114
|
+
if args.get("run"):
|
|
115
|
+
online.start()
|
|
116
|
+
return _status()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def op_sim_stop(args):
|
|
120
|
+
if SIM["online"] is not None:
|
|
121
|
+
_stop()
|
|
122
|
+
return _status()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def op_sim_status(args):
|
|
126
|
+
return _status()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def op_sim_control(args):
|
|
130
|
+
online = _online()
|
|
131
|
+
action = args["action"]
|
|
132
|
+
if action == "run":
|
|
133
|
+
online.start()
|
|
134
|
+
elif action == "stop":
|
|
135
|
+
online.stop()
|
|
136
|
+
elif action in ("reset_warm", "reset_cold"):
|
|
137
|
+
online.reset(se.ResetOption.Warm if action == "reset_warm" else se.ResetOption.Cold, True)
|
|
138
|
+
SIM["forced"].clear() # a reset releases forced values
|
|
139
|
+
else:
|
|
140
|
+
raise OpError("bad_request", "Unknown action: %s" % action)
|
|
141
|
+
return _status()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def op_sim_cycle(args):
|
|
145
|
+
online = _online()
|
|
146
|
+
count = int(args.get("count") or 1)
|
|
147
|
+
if count < 1 or count > MAX_CYCLES:
|
|
148
|
+
raise OpError("bad_request", "count must be between 1 and %d." % MAX_CYCLES)
|
|
149
|
+
if to_text(online.application_state).lower() == "run":
|
|
150
|
+
online.stop()
|
|
151
|
+
command = se.system.commands["online", "singlecycleactiveapp"]
|
|
152
|
+
for _ in range(count):
|
|
153
|
+
command.execute()
|
|
154
|
+
SIM["cycles"] += 1
|
|
155
|
+
_pause(CYCLE_PAUSE_MS)
|
|
156
|
+
return _status()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _read(online, names):
|
|
160
|
+
"""Current values and errors by name. CODESYS answers from its monitoring cache, so
|
|
161
|
+
the first read after a change can return the old value: read once, let it refresh, read again."""
|
|
162
|
+
_read_once(online, names)
|
|
163
|
+
_pause(READ_SETTLE_MS)
|
|
164
|
+
return _read_once(online, names)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_once(online, names):
|
|
168
|
+
"""Values and errors by name. One bad name must not hide the good ones."""
|
|
169
|
+
try:
|
|
170
|
+
return dict(zip(names, [to_text(v) for v in online.read_values(names)])), {}
|
|
171
|
+
except Exception:
|
|
172
|
+
values, errors = {}, {}
|
|
173
|
+
for name in names:
|
|
174
|
+
try:
|
|
175
|
+
values[name] = to_text(online.read_value(name))
|
|
176
|
+
except Exception as e:
|
|
177
|
+
errors[name] = to_text(e)
|
|
178
|
+
return values, errors
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def op_sim_read(args):
|
|
182
|
+
values, errors = _read(_online(), args["names"])
|
|
183
|
+
return {"values": values, "errors": errors}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _prepare(online, values):
|
|
187
|
+
"""Queue values, after checking every name, so a bad name leaves nothing queued."""
|
|
188
|
+
names = sorted(values)
|
|
189
|
+
errors = _read_once(online, names)[1]
|
|
190
|
+
if errors:
|
|
191
|
+
raise OpError("unknown_variable", "; ".join("%s: %s" % (n, errors[n]) for n in sorted(errors)))
|
|
192
|
+
for name in names:
|
|
193
|
+
online.set_prepared_value(name, to_text(values[name]))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def op_sim_write(args):
|
|
197
|
+
online = _online()
|
|
198
|
+
_prepare(online, args["values"])
|
|
199
|
+
online.write_prepared_values()
|
|
200
|
+
return _status()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def op_sim_force(args):
|
|
204
|
+
online = _online()
|
|
205
|
+
_prepare(online, args["values"])
|
|
206
|
+
online.force_prepared_values()
|
|
207
|
+
SIM["forced"].update(args["values"].keys())
|
|
208
|
+
return _status()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def op_sim_unforce(args):
|
|
212
|
+
online = _online()
|
|
213
|
+
names = args.get("names")
|
|
214
|
+
if names:
|
|
215
|
+
for name in names:
|
|
216
|
+
online.set_prepared_value(name, se.online.UNFORCE)
|
|
217
|
+
online.force_prepared_values()
|
|
218
|
+
SIM["forced"].difference_update(names)
|
|
219
|
+
else:
|
|
220
|
+
online.unforce_all_values()
|
|
221
|
+
SIM["forced"].clear()
|
|
222
|
+
return _status()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _locked(fn):
|
|
226
|
+
def run(args):
|
|
227
|
+
if SIM["online"] is not None:
|
|
228
|
+
raise OpError("simulation_running", "The CODESYS simulation is running, so the project is locked. Call codesys_sim_stop first.")
|
|
229
|
+
return fn(args)
|
|
230
|
+
return run
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
for _name in LOCKED:
|
|
234
|
+
mcp_ops.OPS[_name] = _locked(mcp_ops.OPS[_name])
|
|
235
|
+
|
|
236
|
+
mcp_ops.OPS.update({
|
|
237
|
+
"sim.start": op_sim_start,
|
|
238
|
+
"sim.stop": op_sim_stop,
|
|
239
|
+
"sim.status": op_sim_status,
|
|
240
|
+
"sim.control": op_sim_control,
|
|
241
|
+
"sim.cycle": op_sim_cycle,
|
|
242
|
+
"sim.read": op_sim_read,
|
|
243
|
+
"sim.write": op_sim_write,
|
|
244
|
+
"sim.force": op_sim_force,
|
|
245
|
+
"sim.unforce": op_sim_unforce,
|
|
246
|
+
})
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# codesys-mcp-toolkit v2 watcher.
|
|
3
|
+
#
|
|
4
|
+
# Started by the MCP server as: CODESYS.exe --profile="..." --noUI --runscript="<dir>/watcher.py"
|
|
5
|
+
# Environment (inherited from the server):
|
|
6
|
+
# CODESYS_MCP_DIR session folder (requests/, results/, heartbeat.json, ready.json, watcher.log)
|
|
7
|
+
# CODESYS_MCP_SCRIPTS folder holding this file and its modules
|
|
8
|
+
# CODESYS_MCP_PARENT_PID server PID; the watcher exits when it is gone
|
|
9
|
+
#
|
|
10
|
+
# Protocol: the server writes requests/<id>.json ({"id","op","args","deadline_ms"}) via tmp+rename.
|
|
11
|
+
# The watcher handles one request at a time and writes results/<id>.json
|
|
12
|
+
# ({"id","ok","result"} or {"id","ok":false,"error":{"code","message","detail"}}).
|
|
13
|
+
# Requests are JSON op names + arguments; no code is ever generated or exec'd.
|
|
14
|
+
#
|
|
15
|
+
# Keep this file ASCII-only (IronPython 2.7 inside CODESYS).
|
|
16
|
+
from __future__ import print_function
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import traceback
|
|
23
|
+
|
|
24
|
+
SCRIPTS = os.environ.get("CODESYS_MCP_SCRIPTS")
|
|
25
|
+
if SCRIPTS and SCRIPTS not in sys.path:
|
|
26
|
+
sys.path.insert(0, SCRIPTS)
|
|
27
|
+
|
|
28
|
+
from mcp_compat import current_pid, now, pid_alive, read_json, remove_file, retry_fs, to_text, write_json_atomic # noqa: E402
|
|
29
|
+
|
|
30
|
+
SESSION = os.environ["CODESYS_MCP_DIR"]
|
|
31
|
+
PARENT_PID = int(os.environ.get("CODESYS_MCP_PARENT_PID") or "0")
|
|
32
|
+
REQ_DIR = os.path.join(SESSION, "requests")
|
|
33
|
+
RES_DIR = os.path.join(SESSION, "results")
|
|
34
|
+
HEARTBEAT = os.path.join(SESSION, "heartbeat.json")
|
|
35
|
+
READY = os.path.join(SESSION, "ready.json")
|
|
36
|
+
LOG = os.path.join(SESSION, "watcher.log")
|
|
37
|
+
|
|
38
|
+
DIALOG = os.path.join(SESSION, "dialog.json")
|
|
39
|
+
|
|
40
|
+
PID = current_pid()
|
|
41
|
+
STATE = {"count": 0, "last_beat": 0.0, "busy_since": None, "op": None}
|
|
42
|
+
|
|
43
|
+
# A dialog must stay up this long before the server is told, so brief windows don't count.
|
|
44
|
+
DIALOG_GRACE_S = 10.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log(msg):
|
|
48
|
+
try:
|
|
49
|
+
f = open(LOG, "a")
|
|
50
|
+
try:
|
|
51
|
+
f.write("%s %s\n" % (time.strftime("%H:%M:%S"), to_text(msg).encode("ascii", "replace").decode("ascii")))
|
|
52
|
+
finally:
|
|
53
|
+
f.close()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def beat(state, op=None, rid=None, deadline_ms=None):
|
|
59
|
+
# A heartbeat that cannot be written (file held open past the retries) must not
|
|
60
|
+
# kill the watcher: log it and go on. If it keeps failing, the server sees a
|
|
61
|
+
# stale heartbeat and reports the watcher as hung, which is the right signal.
|
|
62
|
+
t = now()
|
|
63
|
+
try:
|
|
64
|
+
write_json_atomic(HEARTBEAT, {
|
|
65
|
+
"pid": PID, "ts": t, "state": state, "op": op, "id": rid,
|
|
66
|
+
"deadline": (t + deadline_ms / 1000.0) if deadline_ms else None,
|
|
67
|
+
"count": STATE["count"],
|
|
68
|
+
})
|
|
69
|
+
except Exception as e:
|
|
70
|
+
log("heartbeat write failed (%s): %s" % (state, to_text(e)))
|
|
71
|
+
STATE["last_beat"] = t
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def setup_prompts(se):
|
|
75
|
+
# Never block on a modal dialog: suppress with defaults and log them.
|
|
76
|
+
# SP21 only has the old prompt_handling; newer versions have script_prompt_handling.
|
|
77
|
+
try:
|
|
78
|
+
se.system.script_prompt_handling = se.ScriptPromptHandling.LogPrompts
|
|
79
|
+
return "script_prompt_handling=LogPrompts"
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
try:
|
|
83
|
+
se.system.prompt_handling = se.PromptHandling.LogSimplePrompts
|
|
84
|
+
return "prompt_handling=LogSimplePrompts"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return "prompt handling not set: %s" % e
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def scripting_version():
|
|
90
|
+
# In CODESYS, sys.version ends in "ScriptEngine.plugin 4.2.0.0".
|
|
91
|
+
m = re.search(r"ScriptEngine\.plugin\s+([0-9.]+)", sys.version)
|
|
92
|
+
return m.group(1) if m else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find_dialog():
|
|
96
|
+
# A session without a UI has no open forms and no main window (checked on SP21 and
|
|
97
|
+
# SP22), so a modal form or a main window means CODESYS is waiting for an answer.
|
|
98
|
+
from System.Windows.Forms import Application
|
|
99
|
+
from System.Diagnostics import Process
|
|
100
|
+
for f in Application.OpenForms:
|
|
101
|
+
if f.Modal:
|
|
102
|
+
return to_text(f.Text) or "(untitled)"
|
|
103
|
+
return to_text(Process.GetCurrentProcess().MainWindowTitle) or None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def watch_dialogs():
|
|
107
|
+
# Runs beside the request loop. A dialog blocks that loop for good and nobody can
|
|
108
|
+
# answer it in a session without a window, so tell the server (dialog.json).
|
|
109
|
+
import clr
|
|
110
|
+
clr.AddReference("System.Windows.Forms")
|
|
111
|
+
title, since, reported, failed = None, 0.0, False, False
|
|
112
|
+
while True:
|
|
113
|
+
time.sleep(1.0)
|
|
114
|
+
busy = STATE["busy_since"]
|
|
115
|
+
current = None
|
|
116
|
+
if busy is not None and now() - busy > 2.0:
|
|
117
|
+
try:
|
|
118
|
+
current = find_dialog()
|
|
119
|
+
except Exception as e: # e.g. the form list changed while it was read
|
|
120
|
+
if not failed:
|
|
121
|
+
log("dialog check failed: %s" % to_text(e))
|
|
122
|
+
failed = True
|
|
123
|
+
if current != title:
|
|
124
|
+
title, since = current, now()
|
|
125
|
+
if current:
|
|
126
|
+
log("dialog up during %s: %s" % (STATE["op"], current))
|
|
127
|
+
elif reported:
|
|
128
|
+
remove_file(DIALOG)
|
|
129
|
+
reported = False
|
|
130
|
+
elif current and not reported and now() - since >= DIALOG_GRACE_S:
|
|
131
|
+
write_json_atomic(DIALOG, {"title": current, "op": STATE["op"]})
|
|
132
|
+
reported = True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def start_dialog_watch():
|
|
136
|
+
import threading
|
|
137
|
+
|
|
138
|
+
def run():
|
|
139
|
+
try:
|
|
140
|
+
watch_dialogs()
|
|
141
|
+
except BaseException as e:
|
|
142
|
+
log("dialog watch stopped: %s" % to_text(e))
|
|
143
|
+
t = threading.Thread(target=run)
|
|
144
|
+
t.daemon = True
|
|
145
|
+
t.start()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sleep_ms(se, ms):
|
|
149
|
+
delay = getattr(getattr(se, "system", None), "delay", None)
|
|
150
|
+
if delay is not None and not hasattr(se, "FAKE"):
|
|
151
|
+
try:
|
|
152
|
+
delay(ms)
|
|
153
|
+
return
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
time.sleep(ms / 1000.0)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def next_request():
|
|
160
|
+
try:
|
|
161
|
+
names = sorted(n for n in os.listdir(REQ_DIR) if n.endswith(".json"))
|
|
162
|
+
except OSError:
|
|
163
|
+
return None
|
|
164
|
+
return names[0] if names else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def handle(ops_mod, fname):
|
|
168
|
+
path = os.path.join(REQ_DIR, fname)
|
|
169
|
+
rid = fname[:-5]
|
|
170
|
+
try:
|
|
171
|
+
# Another process (an antivirus scan, the indexer) can hold a new file for a moment.
|
|
172
|
+
req = retry_fs(read_json, path)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
remove_file(path)
|
|
175
|
+
write_json_atomic(os.path.join(RES_DIR, rid + ".json"), {
|
|
176
|
+
"id": rid, "ok": False, "error": {"code": "bad_request", "message": "Unreadable request: %s" % e}})
|
|
177
|
+
return True
|
|
178
|
+
remove_file(path)
|
|
179
|
+
rid = req.get("id") or rid
|
|
180
|
+
op = req.get("op")
|
|
181
|
+
args = req.get("args") or {}
|
|
182
|
+
STATE["count"] += 1
|
|
183
|
+
beat("busy", op, rid, req.get("deadline_ms"))
|
|
184
|
+
started = now()
|
|
185
|
+
STATE["op"] = op
|
|
186
|
+
STATE["busy_since"] = started
|
|
187
|
+
stop = False
|
|
188
|
+
if op == "shutdown":
|
|
189
|
+
res = {"id": rid, "ok": True, "result": {"bye": True}}
|
|
190
|
+
stop = True
|
|
191
|
+
else:
|
|
192
|
+
fn = ops_mod.OPS.get(op)
|
|
193
|
+
if fn is None:
|
|
194
|
+
res = {"id": rid, "ok": False, "error": {"code": "unknown_op", "message": "Unknown op: %s" % op}}
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
res = {"id": rid, "ok": True, "result": fn(args)}
|
|
198
|
+
except ops_mod.OpError as e:
|
|
199
|
+
res = {"id": rid, "ok": False, "error": {"code": e.code, "message": to_text(e.message)}}
|
|
200
|
+
except BaseException as e: # IronPython: KeyboardInterrupt is not an Exception
|
|
201
|
+
res = {"id": rid, "ok": False, "error": {
|
|
202
|
+
"code": "codesys_error", "message": to_text(e) or e.__class__.__name__,
|
|
203
|
+
"detail": to_text(traceback.format_exc())}}
|
|
204
|
+
STATE["busy_since"] = None
|
|
205
|
+
res["ms"] = int((now() - started) * 1000)
|
|
206
|
+
# Go idle before publishing the result: once the server sees the result it may
|
|
207
|
+
# read the heartbeat right away, and it must not find this op still "busy".
|
|
208
|
+
beat("idle")
|
|
209
|
+
write_json_atomic(os.path.join(RES_DIR, rid + ".json"), res)
|
|
210
|
+
log("%s %s %s %dms" % (rid, op, "ok" if res.get("ok") else res["error"]["code"], res["ms"]))
|
|
211
|
+
return not stop
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main():
|
|
215
|
+
for d in (REQ_DIR, RES_DIR):
|
|
216
|
+
if not os.path.isdir(d):
|
|
217
|
+
os.makedirs(d)
|
|
218
|
+
log("watcher starting pid=%s parent=%s python=%s" % (PID, PARENT_PID, sys.version.split()[0]))
|
|
219
|
+
import scriptengine as se
|
|
220
|
+
import mcp_ops
|
|
221
|
+
import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
|
|
222
|
+
prompts = setup_prompts(se)
|
|
223
|
+
log(prompts)
|
|
224
|
+
if not hasattr(se, "FAKE"):
|
|
225
|
+
start_dialog_watch()
|
|
226
|
+
beat("idle")
|
|
227
|
+
write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version()})
|
|
228
|
+
last_parent_check = 0.0
|
|
229
|
+
while True:
|
|
230
|
+
t = now()
|
|
231
|
+
if PARENT_PID and t - last_parent_check > 2.0:
|
|
232
|
+
last_parent_check = t
|
|
233
|
+
if not pid_alive(PARENT_PID):
|
|
234
|
+
log("parent %s gone; exiting" % PARENT_PID)
|
|
235
|
+
break
|
|
236
|
+
fname = next_request()
|
|
237
|
+
if fname:
|
|
238
|
+
if not handle(mcp_ops, fname):
|
|
239
|
+
log("shutdown requested")
|
|
240
|
+
break
|
|
241
|
+
continue
|
|
242
|
+
if t - STATE["last_beat"] > 1.0:
|
|
243
|
+
beat("idle")
|
|
244
|
+
sleep_ms(se, 40)
|
|
245
|
+
beat("stopped")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
main()
|
|
250
|
+
except BaseException:
|
|
251
|
+
log("watcher crashed: " + traceback.format_exc())
|
|
252
|
+
try:
|
|
253
|
+
beat("crashed")
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(grep:*)",
|
|
5
|
-
"Bash(grep:*)",
|
|
6
|
-
"Bash(npm version:*)",
|
|
7
|
-
"Bash(npm version:*)",
|
|
8
|
-
"Bash(cat:*)",
|
|
9
|
-
"Bash(cat:*)",
|
|
10
|
-
"Bash(npm run build:*)",
|
|
11
|
-
"Bash(npm run build:*)",
|
|
12
|
-
"Bash(npm install:*)",
|
|
13
|
-
"Bash(npm install:*)",
|
|
14
|
-
"Bash(npx tsc:*)",
|
|
15
|
-
"Bash(npx tsc:*)",
|
|
16
|
-
"Bash(npm pack:*)",
|
|
17
|
-
"Bash(npm pack:*)",
|
|
18
|
-
"Bash(npm publish:*)",
|
|
19
|
-
"Bash(npm publish:*)",
|
|
20
|
-
"Bash(ls:*)",
|
|
21
|
-
"Bash(ls:*)",
|
|
22
|
-
"Bash(mkdir:*)",
|
|
23
|
-
"Bash(mkdir:*)",
|
|
24
|
-
"Bash(node:*)",
|
|
25
|
-
"Bash(node:*)"
|
|
26
|
-
],
|
|
27
|
-
"deny": []
|
|
28
|
-
}
|
|
29
|
-
}
|
package/CLAUDE.md
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Build Commands
|
|
6
|
-
- `npm run build` - Compile TypeScript to JavaScript
|
|
7
|
-
- `npm start` - Start the MCP server
|
|
8
|
-
- `tsc` - Run TypeScript compiler manually
|
|
9
|
-
|
|
10
|
-
## Coding Style Guidelines
|
|
11
|
-
- **Imports**: Import 'os' first if needed, then group related imports
|
|
12
|
-
- **Naming**: Use camelCase for variables/functions, PascalCase for classes/interfaces
|
|
13
|
-
- **Types**: TypeScript with interfaces for structured data, avoid implicit 'any'
|
|
14
|
-
- **Error Handling**: Check conditions early, throw meaningful errors with context
|
|
15
|
-
- **Comments**: Add JSDoc comments for functions, especially exported ones
|
|
16
|
-
- **Formatting**: Follow existing indentation (2 spaces) and code patterns
|
|
17
|
-
- **Path Handling**: Use path.join() for cross-platform compatibility
|
|
18
|
-
- **Promises**: Prefer async/await over raw promises when possible
|
|
19
|
-
- **Logging**: Use console.error for diagnostic/debug messages
|
|
20
|
-
|
|
21
|
-
## Project Structure
|
|
22
|
-
- `/src` - TypeScript source files
|
|
23
|
-
- `/scripts` - Python scripts for CODESYS integration
|
|
24
|
-
- `/dist` - Compiled JavaScript (generated)
|
|
25
|
-
- `/docs` - Documentation files
|
|
26
|
-
- `/examples` - Example configurations and usage
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# Contributing to @codesys/mcp-toolkit
|
|
2
|
-
|
|
3
|
-
Thank you for considering contributing to the CODESYS MCP Toolkit! This document provides guidelines and steps for contributing.
|
|
4
|
-
|
|
5
|
-
## Code of Conduct
|
|
6
|
-
|
|
7
|
-
Please be respectful and considerate of others when contributing to this project. We aim to foster an inclusive and welcoming community.
|
|
8
|
-
|
|
9
|
-
## How Can I Contribute?
|
|
10
|
-
|
|
11
|
-
### Reporting Bugs
|
|
12
|
-
|
|
13
|
-
Bug reports help improve the project! When reporting bugs:
|
|
14
|
-
|
|
15
|
-
1. Check if the bug has already been reported
|
|
16
|
-
2. Use the bug report template
|
|
17
|
-
3. Include detailed steps to reproduce the issue
|
|
18
|
-
4. Describe the expected vs. actual behavior
|
|
19
|
-
5. Include screenshots if applicable
|
|
20
|
-
6. Provide environment details (OS, Node.js version, etc.)
|
|
21
|
-
|
|
22
|
-
### Suggesting Features
|
|
23
|
-
|
|
24
|
-
We welcome feature suggestions! When suggesting features:
|
|
25
|
-
|
|
26
|
-
1. Describe the problem your feature would solve
|
|
27
|
-
2. Explain how your feature would work
|
|
28
|
-
3. Consider potential alternatives
|
|
29
|
-
4. Provide examples of similar features in other projects (if applicable)
|
|
30
|
-
|
|
31
|
-
### Pull Requests
|
|
32
|
-
|
|
33
|
-
1. Fork the repository
|
|
34
|
-
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
35
|
-
3. Make your changes
|
|
36
|
-
4. Run tests and ensure they pass
|
|
37
|
-
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
38
|
-
6. Push to your branch (`git push origin feature/amazing-feature`)
|
|
39
|
-
7. Open a Pull Request
|
|
40
|
-
|
|
41
|
-
## Development Setup
|
|
42
|
-
|
|
43
|
-
1. Clone the repository
|
|
44
|
-
```bash
|
|
45
|
-
git clone https://github.com/yourusername/codesys-mcp-toolkit.git
|
|
46
|
-
cd codesys-mcp-toolkit
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
2. Install dependencies
|
|
50
|
-
```bash
|
|
51
|
-
npm install
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
3. Build the project
|
|
55
|
-
```bash
|
|
56
|
-
npm run build
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## Style Guidelines
|
|
60
|
-
|
|
61
|
-
- Follow existing code formatting patterns
|
|
62
|
-
- Use meaningful variable and function names
|
|
63
|
-
- Add comments where necessary to explain complex logic
|
|
64
|
-
- Update documentation when changing functionality
|
|
65
|
-
|
|
66
|
-
## Testing
|
|
67
|
-
|
|
68
|
-
- Add tests for new features
|
|
69
|
-
- Ensure all tests pass before submitting a PR
|
|
70
|
-
- Consider edge cases in your tests
|
|
71
|
-
|
|
72
|
-
Thank you for contributing!
|