@codesys/mcp-toolkit 1.5.6 → 1.5.7
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 +8 -0
- package/codesys-mcp-toolkit-1.5.7.tgz +0 -0
- package/dist/scripts/session_status.py +31 -4
- package/dist/scripts/start_session.py +34 -2
- package/dist/scripts/stop_session.py +159 -55
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.7] - 2025-05-02
|
|
9
|
+
### Fixed
|
|
10
|
+
- Added safety timeout to session scripts to prevent CODESYS UI hanging
|
|
11
|
+
- Improved session detection and management to handle zombie sessions
|
|
12
|
+
- Enhanced session cleanup in stop_session.py with more robust error handling
|
|
13
|
+
- Added maximum session runtime (8 hours) to prevent infinite hanging
|
|
14
|
+
- Improved error detection and recovery in session management scripts
|
|
15
|
+
|
|
8
16
|
## [1.5.6] - 2025-05-02
|
|
9
17
|
### Fixed
|
|
10
18
|
- Fixed session manager null reference error in interop.ts
|
|
Binary file
|
|
@@ -97,6 +97,9 @@ def check_session_status():
|
|
|
97
97
|
except json.JSONDecodeError:
|
|
98
98
|
print("{0}: Invalid session state file".format(SCRIPT_ERROR_MARKER))
|
|
99
99
|
return False
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print("{0}: Error reading session state file: {1}".format(SCRIPT_ERROR_MARKER, str(e)))
|
|
102
|
+
return False
|
|
100
103
|
|
|
101
104
|
# Check if session is active
|
|
102
105
|
active = session_state.get('active', False)
|
|
@@ -107,13 +110,36 @@ def check_session_status():
|
|
|
107
110
|
print("{0}: Session is not active (Status: {1})".format(SCRIPT_SUCCESS_MARKER, status))
|
|
108
111
|
return False
|
|
109
112
|
|
|
113
|
+
# Check for session running too long (> 8 hours)
|
|
114
|
+
start_time = session_state.get('start_time', 0)
|
|
115
|
+
current_time = time.time()
|
|
116
|
+
uptime = current_time - start_time if start_time else 0
|
|
117
|
+
max_allowed_uptime = 60 * 60 * 8 # 8 hours in seconds
|
|
118
|
+
|
|
119
|
+
if uptime > max_allowed_uptime:
|
|
120
|
+
print("WARNING: Session has been running for {0:.1f} hours, exceeding the 8-hour safety limit".format(uptime / 3600))
|
|
121
|
+
|
|
122
|
+
# Update session state to indicate timeout due to excessive runtime
|
|
123
|
+
try:
|
|
124
|
+
session_state['status'] = 'timeout'
|
|
125
|
+
session_state['active'] = False
|
|
126
|
+
session_state['error_reason'] = 'Maximum runtime exceeded'
|
|
127
|
+
|
|
128
|
+
with open(SESSION_STATE_FILE, 'w') as f:
|
|
129
|
+
json.dump(session_state, f, indent=2)
|
|
130
|
+
|
|
131
|
+
print("Updated session state to timeout due to excessive runtime")
|
|
132
|
+
print("{0}: Session is not active (exceeded maximum runtime)".format(SCRIPT_SUCCESS_MARKER))
|
|
133
|
+
return False
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print("Error updating session state: {0}".format(e))
|
|
136
|
+
|
|
110
137
|
# Check session lock file to see if it's been updated recently
|
|
111
138
|
lock_is_stale = False
|
|
112
139
|
if os.path.exists(SESSION_LOCK_FILE):
|
|
113
140
|
try:
|
|
114
141
|
with open(SESSION_LOCK_FILE, 'r') as f:
|
|
115
142
|
lock_time = float(f.read().strip())
|
|
116
|
-
current_time = time.time()
|
|
117
143
|
time_diff = current_time - lock_time
|
|
118
144
|
|
|
119
145
|
# If lock file hasn't been updated in 30 seconds, session might be dead
|
|
@@ -213,9 +239,10 @@ def check_session_status():
|
|
|
213
239
|
|
|
214
240
|
# Include session info
|
|
215
241
|
session_id = session_state.get('session_id', 'Unknown')
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
242
|
+
uptime_str = "{0:.0f} seconds".format(uptime) if uptime < 60 else (
|
|
243
|
+
"{0:.1f} minutes".format(uptime/60) if uptime < 3600 else
|
|
244
|
+
"{0:.1f} hours".format(uptime/3600)
|
|
245
|
+
)
|
|
219
246
|
|
|
220
247
|
if session_active:
|
|
221
248
|
print("{0}: Session is active (ID: {1}, Status: {2}, Uptime: {3}{4})".format(SCRIPT_SUCCESS_MARKER, session_id, status, uptime_str, project_info))
|
|
@@ -648,6 +648,11 @@ def command_loop():
|
|
|
648
648
|
last_state_update = time.time()
|
|
649
649
|
loop_count = 0
|
|
650
650
|
last_heartbeat = time.time()
|
|
651
|
+
start_time = time.time()
|
|
652
|
+
error_count = 0 # Track consecutive errors
|
|
653
|
+
|
|
654
|
+
# Maximum session runtime to prevent infinite hanging (8 hours)
|
|
655
|
+
max_session_runtime = 60 * 60 * 8
|
|
651
656
|
|
|
652
657
|
print("Starting command loop...")
|
|
653
658
|
print("Command file: {0}".format(COMMAND_FILE))
|
|
@@ -656,6 +661,7 @@ def command_loop():
|
|
|
656
661
|
print("Session state file: {0}".format(SESSION_STATE_FILE))
|
|
657
662
|
print("Poll interval: {0} seconds".format(POLL_INTERVAL))
|
|
658
663
|
print("Max inactivity time: {0} seconds".format(MAX_INACTIVITY_TIME))
|
|
664
|
+
print("Max session runtime: {0} hours".format(max_session_runtime / 3600))
|
|
659
665
|
sys.stdout.flush() # Ensure initialization output is visible immediately
|
|
660
666
|
|
|
661
667
|
# Set up initial session state files
|
|
@@ -693,6 +699,17 @@ def command_loop():
|
|
|
693
699
|
loop_count += 1
|
|
694
700
|
current_time = time.time()
|
|
695
701
|
|
|
702
|
+
# Check for absolute maximum session runtime to prevent infinite hanging
|
|
703
|
+
session_runtime = current_time - start_time
|
|
704
|
+
if session_runtime > max_session_runtime:
|
|
705
|
+
print("SAFETY: Maximum session run time of {0} hours reached".format(max_session_runtime / 3600))
|
|
706
|
+
print("SAFETY: This is to prevent CODESYS from hanging indefinitely")
|
|
707
|
+
print("SAFETY: Session will now terminate")
|
|
708
|
+
update_session_state("timeout")
|
|
709
|
+
sys.stdout.flush()
|
|
710
|
+
stop_requested = True
|
|
711
|
+
break
|
|
712
|
+
|
|
696
713
|
# Update session lock file to indicate we're alive
|
|
697
714
|
with open(SESSION_LOCK_FILE, 'w') as f:
|
|
698
715
|
f.write(str(current_time))
|
|
@@ -700,6 +717,7 @@ def command_loop():
|
|
|
700
717
|
# Print heartbeat message every 60 seconds
|
|
701
718
|
if current_time - last_heartbeat > 60:
|
|
702
719
|
print("Command loop heartbeat - still alive (iteration {0})".format(loop_count))
|
|
720
|
+
print("Session running for {0:.1f} minutes".format(session_runtime / 60))
|
|
703
721
|
last_heartbeat = current_time
|
|
704
722
|
sys.stdout.flush()
|
|
705
723
|
|
|
@@ -723,6 +741,9 @@ def command_loop():
|
|
|
723
741
|
last_state_update = current_time
|
|
724
742
|
sys.stdout.flush()
|
|
725
743
|
|
|
744
|
+
# Reset error count on successful iteration
|
|
745
|
+
error_count = 0
|
|
746
|
+
|
|
726
747
|
# Sleep for a bit
|
|
727
748
|
time.sleep(POLL_INTERVAL)
|
|
728
749
|
|
|
@@ -730,8 +751,19 @@ def command_loop():
|
|
|
730
751
|
print("Error in command loop: {0}".format(e))
|
|
731
752
|
traceback.print_exc()
|
|
732
753
|
sys.stdout.flush()
|
|
733
|
-
|
|
734
|
-
|
|
754
|
+
|
|
755
|
+
# Increment error count
|
|
756
|
+
error_count += 1
|
|
757
|
+
|
|
758
|
+
# If we're getting repeated errors, consider breaking the loop
|
|
759
|
+
if error_count > 10:
|
|
760
|
+
print("SAFETY: Too many consecutive errors ({0}), stopping session".format(error_count))
|
|
761
|
+
update_session_state("error")
|
|
762
|
+
sys.stdout.flush()
|
|
763
|
+
stop_requested = True
|
|
764
|
+
else:
|
|
765
|
+
# Sleep for a bit before retrying
|
|
766
|
+
time.sleep(POLL_INTERVAL)
|
|
735
767
|
|
|
736
768
|
print("Command loop stopped after {0} iterations".format(loop_count))
|
|
737
769
|
sys.stdout.flush()
|
|
@@ -85,65 +85,157 @@ print("SESSION_LOCK_FILE: {0}".format(SESSION_LOCK_FILE))
|
|
|
85
85
|
def stop_session():
|
|
86
86
|
"""Stop a running CODESYS session"""
|
|
87
87
|
try:
|
|
88
|
+
print("Attempting to stop CODESYS session")
|
|
89
|
+
|
|
88
90
|
# Check if session directory exists
|
|
89
91
|
if not os.path.exists(SESSION_DIR):
|
|
90
|
-
print("{0}: No session directory found at {1}".format(
|
|
91
|
-
|
|
92
|
+
print("{0}: No session directory found at {1}".format(SCRIPT_SUCCESS_MARKER, SESSION_DIR))
|
|
93
|
+
# Consider this a success since there's no session to stop
|
|
94
|
+
return True
|
|
92
95
|
|
|
93
96
|
# Check if session state file exists
|
|
94
97
|
if not os.path.exists(SESSION_STATE_FILE):
|
|
95
|
-
print("{0}: No session state file found at {1}".format(
|
|
96
|
-
|
|
98
|
+
print("{0}: No session state file found at {1}".format(SCRIPT_SUCCESS_MARKER, SESSION_STATE_FILE))
|
|
99
|
+
# No state file means no active session - clear any other potential session files
|
|
100
|
+
try:
|
|
101
|
+
for filename in os.listdir(SESSION_DIR):
|
|
102
|
+
filepath = os.path.join(SESSION_DIR, filename)
|
|
103
|
+
try:
|
|
104
|
+
if os.path.isfile(filepath):
|
|
105
|
+
os.unlink(filepath)
|
|
106
|
+
print("Removed orphaned session file: {0}".format(filepath))
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print("Error removing file {0}: {1}".format(filepath, e))
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print("Error cleaning session directory: {0}".format(e))
|
|
111
|
+
|
|
112
|
+
return True
|
|
97
113
|
|
|
98
114
|
# Read session state
|
|
99
115
|
try:
|
|
100
116
|
with open(SESSION_STATE_FILE, 'r') as f:
|
|
101
117
|
session_state = json.load(f)
|
|
102
|
-
except
|
|
103
|
-
print("
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
except Exception as e:
|
|
119
|
+
print("Error reading session state file: {0}".format(e))
|
|
120
|
+
# Force cleanup regardless of the error
|
|
121
|
+
try:
|
|
122
|
+
for filename in os.listdir(SESSION_DIR):
|
|
123
|
+
filepath = os.path.join(SESSION_DIR, filename)
|
|
124
|
+
try:
|
|
125
|
+
if os.path.isfile(filepath):
|
|
126
|
+
os.unlink(filepath)
|
|
127
|
+
print("Removed session file due to corrupt state: {0}".format(filepath))
|
|
128
|
+
except Exception as rm_err:
|
|
129
|
+
print("Error removing file {0}: {1}".format(filepath, rm_err))
|
|
130
|
+
except Exception as dir_err:
|
|
131
|
+
print("Error cleaning session directory: {0}".format(dir_err))
|
|
132
|
+
|
|
133
|
+
print("{0}: Session files cleaned up forcefully due to corrupt state".format(SCRIPT_SUCCESS_MARKER))
|
|
109
134
|
return True
|
|
110
135
|
|
|
111
|
-
# Check if
|
|
112
|
-
if not
|
|
113
|
-
print("{0}:
|
|
114
|
-
|
|
136
|
+
# Check if session is already marked as inactive
|
|
137
|
+
if not session_state.get('active', False) or session_state.get('status') in ['stopped', 'error', 'timeout']:
|
|
138
|
+
print("{0}: Session is already marked as {1}".format(
|
|
139
|
+
SCRIPT_SUCCESS_MARKER, session_state.get('status', 'inactive')))
|
|
140
|
+
|
|
141
|
+
# Remove session files to ensure a clean state
|
|
142
|
+
try:
|
|
143
|
+
for filename in os.listdir(SESSION_DIR):
|
|
144
|
+
filepath = os.path.join(SESSION_DIR, filename)
|
|
145
|
+
try:
|
|
146
|
+
if os.path.isfile(filepath):
|
|
147
|
+
os.unlink(filepath)
|
|
148
|
+
print("Removed session file for cleanup: {0}".format(filepath))
|
|
149
|
+
except Exception as e:
|
|
150
|
+
print("Error removing file {0}: {1}".format(filepath, e))
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print("Error cleaning session directory: {0}".format(e))
|
|
153
|
+
|
|
154
|
+
return True
|
|
115
155
|
|
|
116
|
-
#
|
|
117
|
-
|
|
156
|
+
# Get lock file age to check if session is responsive
|
|
157
|
+
lock_is_stale = False
|
|
158
|
+
current_time = time.time()
|
|
159
|
+
|
|
118
160
|
try:
|
|
119
161
|
if os.path.exists(SESSION_LOCK_FILE):
|
|
120
162
|
with open(SESSION_LOCK_FILE, 'r') as f:
|
|
121
163
|
lock_time = float(f.read().strip())
|
|
164
|
+
lock_age = current_time - lock_time
|
|
122
165
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
166
|
+
if lock_age > 30:
|
|
167
|
+
print("WARNING: Session lock file is stale ({0:.1f} seconds old). Session may have crashed.".format(lock_age))
|
|
168
|
+
lock_is_stale = True
|
|
169
|
+
else:
|
|
170
|
+
print("WARNING: Session lock file not found. Session may have crashed.")
|
|
171
|
+
lock_is_stale = True
|
|
126
172
|
except Exception as e:
|
|
127
173
|
print("Warning: Error reading lock file: {0}".format(e))
|
|
174
|
+
lock_is_stale = True
|
|
128
175
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
176
|
+
# If the command file doesn't exist, we have a zombie session
|
|
177
|
+
if not os.path.exists(COMMAND_FILE):
|
|
178
|
+
print("WARNING: Command file not found. Session is in a zombie state.")
|
|
179
|
+
lock_is_stale = True
|
|
180
|
+
|
|
181
|
+
# If the session appears to be a zombie (stale lock), don't try to send a command
|
|
182
|
+
# Just force cleanup immediately
|
|
183
|
+
if lock_is_stale:
|
|
184
|
+
print("Session appears to be unresponsive. Skipping command and forcing cleanup.")
|
|
185
|
+
try:
|
|
186
|
+
# Update state file first
|
|
187
|
+
try:
|
|
188
|
+
session_state['active'] = False
|
|
189
|
+
session_state['status'] = "error"
|
|
190
|
+
session_state['end_time'] = current_time
|
|
191
|
+
session_state['error_reason'] = "Unresponsive session, forced cleanup"
|
|
192
|
+
|
|
193
|
+
with open(SESSION_STATE_FILE, 'w') as f:
|
|
194
|
+
json.dump(session_state, f, indent=2)
|
|
195
|
+
print("Updated session state to error before cleanup")
|
|
196
|
+
except Exception as state_err:
|
|
197
|
+
print("Warning: Failed to update session state file: {0}".format(state_err))
|
|
198
|
+
|
|
199
|
+
# Remove all session files
|
|
200
|
+
for filename in os.listdir(SESSION_DIR):
|
|
201
|
+
filepath = os.path.join(SESSION_DIR, filename)
|
|
202
|
+
try:
|
|
203
|
+
if os.path.isfile(filepath):
|
|
204
|
+
os.unlink(filepath)
|
|
205
|
+
print("Removed zombie session file: {0}".format(filepath))
|
|
206
|
+
except Exception as e:
|
|
207
|
+
print("Error removing file {0}: {1}".format(filepath, e))
|
|
208
|
+
|
|
209
|
+
print("{0}: Zombie session files cleaned up forcefully".format(SCRIPT_SUCCESS_MARKER))
|
|
210
|
+
return True
|
|
211
|
+
except Exception as e:
|
|
212
|
+
print("{0}: Failed to force cleanup - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
# For responsive sessions, try to send a stop command first
|
|
216
|
+
try:
|
|
217
|
+
# Create stop command
|
|
218
|
+
stop_command = {
|
|
219
|
+
"id": "stop_{0}".format(int(time.time())),
|
|
220
|
+
"type": "stop",
|
|
221
|
+
"timestamp": current_time
|
|
222
|
+
}
|
|
135
223
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
224
|
+
# Write command to file with pretty-printing
|
|
225
|
+
command_data = {
|
|
226
|
+
"status": "pending",
|
|
227
|
+
"command": stop_command,
|
|
228
|
+
"timestamp": current_time
|
|
229
|
+
}
|
|
142
230
|
|
|
143
|
-
|
|
144
|
-
|
|
231
|
+
print("Writing stop command to command file: {0}".format(COMMAND_FILE))
|
|
232
|
+
with open(COMMAND_FILE, 'w') as f:
|
|
233
|
+
json.dump(command_data, f, indent=2)
|
|
145
234
|
|
|
146
|
-
|
|
235
|
+
print("Stop command sent to session")
|
|
236
|
+
except Exception as cmd_err:
|
|
237
|
+
print("Error sending stop command: {0}".format(cmd_err))
|
|
238
|
+
# Continue to forced cleanup
|
|
147
239
|
|
|
148
240
|
# Wait for result (with timeout)
|
|
149
241
|
timeout = 15 # seconds (increased timeout for more reliability)
|
|
@@ -181,35 +273,47 @@ def stop_session():
|
|
|
181
273
|
|
|
182
274
|
with open(SESSION_STATE_FILE, 'w') as f:
|
|
183
275
|
json.dump(session_state, f, indent=2)
|
|
276
|
+
print("Updated session state to stopped")
|
|
184
277
|
except Exception as e:
|
|
185
278
|
print("Warning: Failed to update session state file: {0}".format(e))
|
|
186
279
|
|
|
187
|
-
|
|
188
|
-
|
|
280
|
+
# Remove all session files for a clean state
|
|
281
|
+
print("Cleaning up all session files")
|
|
282
|
+
try:
|
|
283
|
+
for filename in os.listdir(SESSION_DIR):
|
|
284
|
+
filepath = os.path.join(SESSION_DIR, filename)
|
|
285
|
+
try:
|
|
286
|
+
if os.path.isfile(filepath):
|
|
287
|
+
os.unlink(filepath)
|
|
288
|
+
print("Removed session file: {0}".format(filepath))
|
|
289
|
+
except Exception as e:
|
|
290
|
+
print("Error removing file {0}: {1}".format(filepath, e))
|
|
291
|
+
|
|
292
|
+
print("{0}: Session stopped and files cleaned up successfully".format(SCRIPT_SUCCESS_MARKER))
|
|
189
293
|
return True
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print("{0}: Error during final cleanup - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print("{0}: Error stopping session - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
|
|
300
|
+
traceback.print_exc()
|
|
301
|
+
|
|
302
|
+
# Try one final desperate cleanup even if everything else failed
|
|
303
|
+
try:
|
|
304
|
+
if os.path.exists(SESSION_DIR):
|
|
196
305
|
for filename in os.listdir(SESSION_DIR):
|
|
197
306
|
filepath = os.path.join(SESSION_DIR, filename)
|
|
198
307
|
try:
|
|
199
308
|
if os.path.isfile(filepath):
|
|
200
309
|
os.unlink(filepath)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
print("
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return False
|
|
209
|
-
|
|
210
|
-
except Exception as e:
|
|
211
|
-
print("{0}: Error stopping session - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
|
|
212
|
-
traceback.print_exc()
|
|
310
|
+
print("Emergency cleanup: Removed session file: {0}".format(filepath))
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
print("Emergency session cleanup attempted after error")
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
|
|
213
317
|
return False
|
|
214
318
|
|
|
215
319
|
# Main script
|
package/dist/server.js
CHANGED
|
@@ -177,7 +177,7 @@ function startMcpServer(config) {
|
|
|
177
177
|
console.error("SERVER.TS: Initializing McpServer...");
|
|
178
178
|
const server = new mcp_js_1.McpServer({
|
|
179
179
|
name: "CODESYS Control MCP Server",
|
|
180
|
-
version: "1.8.
|
|
180
|
+
version: "1.8.2", // Updated version with session safety improvements
|
|
181
181
|
capabilities: {
|
|
182
182
|
resources: { listChanged: true },
|
|
183
183
|
tools: { listChanged: true }
|