@codesys/mcp-toolkit 1.3.0 → 1.4.1

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.
@@ -18,7 +18,9 @@
18
18
  "Bash(npm publish:*)",
19
19
  "Bash(npm publish:*)",
20
20
  "Bash(ls:*)",
21
- "Bash(ls:*)"
21
+ "Bash(ls:*)",
22
+ "Bash(mkdir:*)",
23
+ "Bash(mkdir:*)"
22
24
  ],
23
25
  "deny": []
24
26
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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.4.1] - 2025-05-02
9
+ ### Fixed
10
+ - Fixed IronPython 2.7 compatibility for session management scripts
11
+ - Replaced f-strings with str.format() method calls for IronPython compatibility
12
+ - Added fallback import for json module (using simplejson for IronPython)
13
+ - Improved error handling for session scripts
14
+ - Re-enabled session management feature in codesys_interop.ts
15
+
16
+ ## [1.4.0] - 2025-05-02
17
+ ### Added
18
+ - Implemented initial framework for session management
19
+ - Added foundation for file-based IPC between Node.js and CODESYS instances
20
+ - Created documentation structure for session management capabilities
21
+
22
+ ## [1.3.0] - 2025-05-02
23
+ ### Changed
24
+ - Refactored codebase for improved reliability and maintainability
25
+ - Enhanced error handling in Python script templates
26
+
8
27
  ## [1.2.4] - 2025-05-02
9
28
  ### Fixed
10
29
  - Fully disabled session management to prevent operations appearing to succeed but not actually running
@@ -92,7 +92,7 @@ function executeCodesysScript(scriptContent, codesysExePath, codesysProfileName,
92
92
  throw new Error(`CODESYS executable not found at provided path: ${codesysExePath}`);
93
93
  // --- End Pre-checks ---
94
94
  // Enable or disable session management
95
- const SESSION_FEATURE_DISABLED = false;
95
+ const SESSION_FEATURE_DISABLED = false; // Session feature enabled
96
96
  // --- Check if we should use the persistent session ---
97
97
  if (!SESSION_FEATURE_DISABLED && sessionManager) {
98
98
  const sessionState = sessionManager.getState();
@@ -1,6 +1,13 @@
1
- import sys, os, json, time
1
+ import sys, os, time, traceback
2
2
  import tempfile
3
3
 
4
+ # Import JSON handling for IronPython 2.7 compatibility
5
+ try:
6
+ import json
7
+ except ImportError:
8
+ # For IronPython 2.7, we need to use simplejson instead
9
+ import simplejson as json
10
+
4
11
  # Define markers for script results
5
12
  SCRIPT_SUCCESS_MARKER = "SCRIPT_SUCCESS"
6
13
  SCRIPT_ERROR_MARKER = "SCRIPT_ERROR"
@@ -17,27 +24,33 @@ def check_session_status():
17
24
  try:
18
25
  # Check if session directory exists
19
26
  if not os.path.exists(SESSION_DIR):
20
- print(f"{SCRIPT_SUCCESS_MARKER}: No session active. Session directory not found.")
27
+ print("{0}: No session active. Session directory not found.".format(SCRIPT_SUCCESS_MARKER))
21
28
  return False
22
29
 
23
30
  # Check if session state file exists
24
31
  if not os.path.exists(SESSION_STATE_FILE):
25
- print(f"{SCRIPT_SUCCESS_MARKER}: No session active. Session state file not found.")
32
+ print("{0}: No session active. Session state file not found.".format(SCRIPT_SUCCESS_MARKER))
26
33
  return False
27
34
 
28
35
  # Read session state
29
- with open(SESSION_STATE_FILE, 'r') as f:
30
- try:
36
+ try:
37
+ with open(SESSION_STATE_FILE, 'r') as f:
31
38
  session_state = json.load(f)
32
- except json.JSONDecodeError:
33
- print(f"{SCRIPT_ERROR_MARKER}: Invalid session state file")
34
- return False
39
+ except json.JSONDecodeError:
40
+ print("{0}: Invalid session state file".format(SCRIPT_ERROR_MARKER))
41
+ return False
35
42
 
36
43
  # Check if session is active
37
44
  active = session_state.get('active', False)
38
45
  status = session_state.get('status', 'unknown')
39
46
 
47
+ # If the status indicates the session is not active, return immediately
48
+ if not active or status in ['stopped', 'error', 'stopping', 'timeout']:
49
+ print("{0}: Session is not active (Status: {1})".format(SCRIPT_SUCCESS_MARKER, status))
50
+ return False
51
+
40
52
  # Check session lock file to see if it's been updated recently
53
+ lock_is_stale = False
41
54
  if os.path.exists(SESSION_LOCK_FILE):
42
55
  try:
43
56
  with open(SESSION_LOCK_FILE, 'r') as f:
@@ -47,26 +60,33 @@ def check_session_status():
47
60
 
48
61
  # If lock file hasn't been updated in 30 seconds, session might be dead
49
62
  if time_diff > 30:
50
- print(f"{SCRIPT_SUCCESS_MARKER}: Session appears to be inactive (lock file stale by {time_diff:.1f} seconds)")
51
- # But we'll leave the active flag as is, since it might just be busy
63
+ print("WARNING: Session lock file is stale by {0:.1f} seconds".format(time_diff))
64
+ lock_is_stale = True
52
65
  except Exception as e:
53
- print(f"Error reading lock file: {e}")
66
+ print("Error reading lock file: {0}".format(e))
67
+ lock_is_stale = True
68
+ else:
69
+ print("WARNING: Session lock file not found")
70
+ lock_is_stale = True
54
71
 
55
- # Execute a ping test by sending a simple command
56
- if active and status in ["running", "busy"]:
72
+ # If the lock file is stale but other checks suggest the session is active,
73
+ # we'll perform a ping test to be sure
74
+ ping_success = False
75
+ if status in ["running", "busy"]:
57
76
  # Try to ping the session
58
77
  try:
59
78
  # Check if command file exists
60
79
  if os.path.exists(COMMAND_FILE):
80
+ print("Sending ping command to verify session...")
81
+
61
82
  # Create ping command
62
83
  ping_command = {
63
- "id": f"ping_{int(time.time())}",
64
- "type": "execute_script",
65
- "script": "print('SCRIPT_SUCCESS: Ping successful')",
84
+ "id": "ping_{0}".format(int(time.time())),
85
+ "type": "ping", # Use specific ping command type
66
86
  "timestamp": time.time()
67
87
  }
68
88
 
69
- # Write command to file
89
+ # Write command to file with pretty-printing
70
90
  command_data = {
71
91
  "status": "pending",
72
92
  "command": ping_command,
@@ -74,12 +94,11 @@ def check_session_status():
74
94
  }
75
95
 
76
96
  with open(COMMAND_FILE, 'w') as f:
77
- json.dump(command_data, f)
97
+ json.dump(command_data, f, indent=2)
78
98
 
79
99
  # Wait for result (with short timeout)
80
100
  timeout = 5 # seconds
81
101
  start_time = time.time()
82
- ping_success = False
83
102
 
84
103
  while not ping_success and (time.time() - start_time) < timeout:
85
104
  # Check if result file exists
@@ -95,33 +114,78 @@ def check_session_status():
95
114
  result = result_data.get("result")
96
115
  if result.get('id') == ping_command['id'] and result.get('success', False):
97
116
  ping_success = True
117
+ print("Ping successful, session is responsive")
98
118
  break
99
119
  except Exception as e:
100
- print(f"Error reading result: {e}")
120
+ print("Error reading result: {0}".format(e))
101
121
  time.sleep(0.5)
102
122
 
103
123
  if not ping_success:
104
- print(f"{SCRIPT_SUCCESS_MARKER}: Session appears to be unresponsive to ping")
105
- return False
124
+ print("WARNING: Session did not respond to ping within {0} seconds".format(timeout))
125
+
126
+ # Reset command file since ping failed
127
+ try:
128
+ command_data = {
129
+ "status": "ready",
130
+ "command": None,
131
+ "timestamp": time.time()
132
+ }
133
+
134
+ with open(COMMAND_FILE, 'w') as f:
135
+ json.dump(command_data, f, indent=2)
136
+
137
+ print("Reset command file after failed ping")
138
+ except Exception as e:
139
+ print("Error resetting command file: {0}".format(e))
140
+ else:
141
+ print("WARNING: Command file not found, cannot ping session")
106
142
  except Exception as e:
107
- print(f"Error during ping test: {e}")
143
+ print("Error during ping test: {0}".format(e))
144
+ traceback.print_exc()
145
+
146
+ # Make final determination based on all checks
147
+ session_active = active and (not lock_is_stale or ping_success) and status in ["running", "busy"]
108
148
 
109
149
  # Build status message
110
150
  project_info = ""
111
151
  if session_state.get('project_open', False):
112
152
  project_name = session_state.get('project_name', 'Unknown')
113
153
  project_path = session_state.get('project_path', 'Unknown')
114
- project_info = f", Project: {project_name} ({project_path})"
154
+ project_info = ", Project: {0} ({1})".format(project_name, project_path)
115
155
 
116
- if active and status in ["running", "busy"]:
117
- print(f"{SCRIPT_SUCCESS_MARKER}: Session is active (Status: {status}{project_info})")
156
+ # Include session info
157
+ session_id = session_state.get('session_id', 'Unknown')
158
+ start_time = session_state.get('start_time', 0)
159
+ uptime = time.time() - start_time if start_time else 0
160
+ uptime_str = "{0:.0f} seconds".format(uptime) if uptime < 60 else "{0:.1f} minutes".format(uptime/60)
161
+
162
+ if session_active:
163
+ print("{0}: Session is active (ID: {1}, Status: {2}, Uptime: {3}{4})".format(SCRIPT_SUCCESS_MARKER, session_id, status, uptime_str, project_info))
118
164
  return True
119
165
  else:
120
- print(f"{SCRIPT_SUCCESS_MARKER}: Session is not active (Status: {status})")
166
+ # If we get here, session may be in a state where files exist but process is dead
167
+ if active and status in ["running", "busy"] and lock_is_stale and not ping_success:
168
+ print("{0}: Session appears to be zombie (ID: {1}, Status: {2} but unresponsive)".format(SCRIPT_SUCCESS_MARKER, session_id, status))
169
+ # Update session state to error so it will be considered inactive
170
+ try:
171
+ session_state['status'] = 'error'
172
+ session_state['active'] = False
173
+ session_state['error_reason'] = 'Unresponsive to ping'
174
+
175
+ with open(SESSION_STATE_FILE, 'w') as f:
176
+ json.dump(session_state, f, indent=2)
177
+
178
+ print("Updated session state to error due to unresponsiveness")
179
+ except Exception as e:
180
+ print("Error updating session state: {0}".format(e))
181
+ else:
182
+ print("{0}: Session is not active (Status: {1})".format(SCRIPT_SUCCESS_MARKER, status))
183
+
121
184
  return False
122
185
 
123
186
  except Exception as e:
124
- print(f"{SCRIPT_ERROR_MARKER}: Error checking session status - {str(e)}")
187
+ print("{0}: Error checking session status - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
188
+ traceback.print_exc()
125
189
  return False
126
190
 
127
191
  # Main script
@@ -1,5 +1,19 @@
1
- import sys, os, json, time, tempfile
2
- import scriptengine as script_engine
1
+ import sys, os, time, tempfile, traceback
2
+
3
+ # Import JSON handling for IronPython 2.7 compatibility
4
+ try:
5
+ import json
6
+ except ImportError:
7
+ # For IronPython 2.7, we need to use simplejson instead
8
+ import simplejson as json
9
+
10
+ # Import scriptengine
11
+ try:
12
+ import scriptengine as script_engine
13
+ except ImportError:
14
+ # Log the error and continue - we'll handle this gracefully
15
+ print("Error importing scriptengine. This script must be run within CODESYS environment.")
16
+ script_engine = None
3
17
 
4
18
  # Define markers for script results
5
19
  SCRIPT_SUCCESS_MARKER = "SCRIPT_SUCCESS"
@@ -11,47 +25,75 @@ SESSION_STATE_FILE = os.path.join(SESSION_DIR, "session_state.json")
11
25
  COMMAND_FILE = os.path.join(SESSION_DIR, "command.json")
12
26
  RESULT_FILE = os.path.join(SESSION_DIR, "result.json")
13
27
  SESSION_LOCK_FILE = os.path.join(SESSION_DIR, "session.lock")
14
- POLL_INTERVAL = 0.5 # seconds
28
+ POLL_INTERVAL = 0.1 # seconds (faster polling for better responsiveness)
29
+ MAX_INACTIVITY_TIME = 600 # seconds (10 minutes of inactivity before auto-shutdown)
15
30
 
16
31
  def setup_session_dir():
17
32
  """Create the session directory and initial files"""
18
33
  try:
34
+ # Clean up any existing session first
35
+ if os.path.exists(SESSION_DIR):
36
+ try:
37
+ # Try to read existing session state
38
+ if os.path.exists(SESSION_STATE_FILE):
39
+ try:
40
+ with open(SESSION_STATE_FILE, 'r') as f:
41
+ old_state = json.load(f)
42
+ print("Cleaning up previous session (ID: {0})".format(old_state.get('session_id', 'unknown')))
43
+ except:
44
+ pass
45
+
46
+ # Remove all files in the directory
47
+ for filename in os.listdir(SESSION_DIR):
48
+ filepath = os.path.join(SESSION_DIR, filename)
49
+ try:
50
+ if os.path.isfile(filepath):
51
+ os.unlink(filepath)
52
+ except Exception as e:
53
+ print("Error removing file {0}: {1}".format(filepath, e))
54
+ except Exception as e:
55
+ print("Error cleaning up previous session: {0}".format(e))
56
+
19
57
  # Create session directory
20
58
  if not os.path.exists(SESSION_DIR):
21
59
  os.makedirs(SESSION_DIR)
22
60
 
23
61
  # Initialize session state
62
+ session_id = str(time.time()) + "_" + str(os.getpid())
24
63
  session_state = {
25
64
  "active": True,
26
- "session_id": str(time.time()),
65
+ "session_id": session_id,
27
66
  "start_time": time.time(),
67
+ "last_activity": time.time(),
28
68
  "status": "running",
29
69
  "project_open": False,
30
70
  "project_name": "",
31
- "project_path": ""
71
+ "project_path": "",
72
+ "version": "1.0"
32
73
  }
33
74
 
34
75
  # Write session state
35
76
  with open(SESSION_STATE_FILE, 'w') as f:
36
- json.dump(session_state, f)
77
+ json.dump(session_state, f, indent=2)
37
78
 
38
79
  # Create empty command file
39
80
  with open(COMMAND_FILE, 'w') as f:
40
- json.dump({"status": "ready", "command": None, "timestamp": time.time()}, f)
81
+ json.dump({"status": "ready", "command": None, "timestamp": time.time()}, f, indent=2)
41
82
 
42
83
  # Create empty result file
43
84
  with open(RESULT_FILE, 'w') as f:
44
- json.dump({"status": "none", "result": None, "timestamp": time.time()}, f)
85
+ json.dump({"status": "none", "result": None, "timestamp": time.time()}, f, indent=2)
45
86
 
46
87
  # Create lock file
47
88
  with open(SESSION_LOCK_FILE, 'w') as f:
48
89
  f.write(str(time.time()))
49
90
 
50
- print(f"Session directory setup at: {SESSION_DIR}")
91
+ print("Session directory setup at: {0}".format(SESSION_DIR))
92
+ print("Session ID: {0}".format(session_id))
51
93
  return True
52
94
  except Exception as e:
53
- print(f"Failed to set up session directory: {e}")
54
- print(f"{SCRIPT_ERROR_MARKER}: Session setup failed - {str(e)}")
95
+ print("Failed to set up session directory: {0}".format(e))
96
+ print("{0}: Session setup failed - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
55
97
  return False
56
98
 
57
99
  def update_session_state(status="running"):
@@ -62,24 +104,44 @@ def update_session_state(status="running"):
62
104
  project_name = "No project open"
63
105
  project_path = "N/A"
64
106
 
65
- primary_project = script_engine.projects.primary
66
- if primary_project:
67
- project_open = True
107
+ # Check if script_engine was properly imported
108
+ if script_engine is None:
109
+ print("Warning: scriptengine not available, cannot get project info")
110
+ else:
111
+ # Try to get primary project
68
112
  try:
69
- project_path = os.path.normcase(os.path.abspath(primary_project.path))
70
- try:
71
- project_name = primary_project.get_name()
72
- if not project_name:
73
- project_name = f"Unnamed (path: {os.path.basename(project_path)})"
74
- except:
75
- project_name = f"Unnamed (path: {os.path.basename(project_path)})"
76
- except Exception as e_path:
77
- project_path = f"N/A (Error: {e_path})"
78
- project_name = "Unnamed (Path Error)"
113
+ primary_project = script_engine.projects.primary
114
+ if primary_project:
115
+ project_open = True
116
+ try:
117
+ project_path = os.path.normcase(os.path.abspath(primary_project.path))
118
+ try:
119
+ project_name = primary_project.get_name()
120
+ if not project_name:
121
+ project_name = "Unnamed (path: {0})".format(os.path.basename(project_path))
122
+ except:
123
+ project_name = "Unnamed (path: {0})".format(os.path.basename(project_path))
124
+ except Exception as e_path:
125
+ project_path = "N/A (Error: {0})".format(str(e_path))
126
+ project_name = "Unnamed (Path Error)"
127
+ except Exception as e:
128
+ print("Error accessing script_engine.projects: {0}".format(str(e)))
129
+ traceback.print_exc()
79
130
 
80
131
  # Read current state file
81
- with open(SESSION_STATE_FILE, 'r') as f:
82
- session_state = json.load(f)
132
+ try:
133
+ with open(SESSION_STATE_FILE, 'r') as f:
134
+ session_state = json.load(f)
135
+ except (FileNotFoundError, json.JSONDecodeError):
136
+ # State file doesn't exist or is invalid, create a new one
137
+ session_id = str(time.time()) + "_" + str(os.getpid())
138
+ session_state = {
139
+ "active": True,
140
+ "session_id": session_id,
141
+ "start_time": time.time(),
142
+ "status": status,
143
+ "version": "1.0"
144
+ }
83
145
 
84
146
  # Update state
85
147
  session_state["project_open"] = project_open
@@ -90,11 +152,12 @@ def update_session_state(status="running"):
90
152
 
91
153
  # Write updated state
92
154
  with open(SESSION_STATE_FILE, 'w') as f:
93
- json.dump(session_state, f)
155
+ json.dump(session_state, f, indent=2)
94
156
 
95
157
  return True
96
158
  except Exception as e:
97
- print(f"Failed to update session state: {e}")
159
+ print("Failed to update session state: {0}".format(e))
160
+ traceback.print_exc()
98
161
  return False
99
162
 
100
163
  def execute_command(command_data):
@@ -114,6 +177,10 @@ def execute_command(command_data):
114
177
  if cmd_type == "execute_script" and script:
115
178
  # Execute the script in the current CODESYS context
116
179
  try:
180
+ # Create a temporary globals dictionary with the current globals
181
+ # This allows us to isolate the script execution somewhat
182
+ local_globals = globals().copy()
183
+
117
184
  # Capture output by redirecting stdout/stderr
118
185
  import io
119
186
  from contextlib import redirect_stdout, redirect_stderr
@@ -123,7 +190,7 @@ def execute_command(command_data):
123
190
 
124
191
  with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
125
192
  # Execute the script
126
- exec(script, globals())
193
+ exec(script, local_globals)
127
194
 
128
195
  # Get the captured output
129
196
  stdout_output = stdout_buffer.getvalue()
@@ -143,14 +210,15 @@ def execute_command(command_data):
143
210
  result["error"] = stderr_output
144
211
 
145
212
  except Exception as e:
213
+ traceback.print_exc()
146
214
  result["success"] = False
147
- result["error"] = f"Error executing script: {str(e)}"
148
- result["output"] = f"{SCRIPT_ERROR_MARKER}: {str(e)}"
215
+ result["error"] = "Error executing script: {0}\n{1}".format(str(e), traceback.format_exc())
216
+ result["output"] = "{0}: {1}".format(SCRIPT_ERROR_MARKER, str(e))
149
217
 
150
218
  elif cmd_type == "stop":
151
219
  # Stop command received
152
220
  result["success"] = True
153
- result["output"] = f"{SCRIPT_SUCCESS_MARKER}: Session stopping"
221
+ result["output"] = "{0}: Session stopping".format(SCRIPT_SUCCESS_MARKER)
154
222
 
155
223
  # Update session state to mark as stopping
156
224
  update_session_state("stopping")
@@ -158,10 +226,15 @@ def execute_command(command_data):
158
226
  # Return the result and signal to stop the loop
159
227
  return result, True
160
228
 
229
+ elif cmd_type == "ping":
230
+ # Simple ping command to check if session is alive
231
+ result["success"] = True
232
+ result["output"] = "{0}: Session is active".format(SCRIPT_SUCCESS_MARKER)
233
+
161
234
  else:
162
235
  result["success"] = False
163
- result["error"] = f"Unknown command type: {cmd_type}"
164
- result["output"] = f"{SCRIPT_ERROR_MARKER}: Unknown command type: {cmd_type}"
236
+ result["error"] = "Unknown command type: {0}".format(cmd_type)
237
+ result["output"] = "{0}: Unknown command type: {1}".format(SCRIPT_ERROR_MARKER, cmd_type)
165
238
 
166
239
  # Update session state after command execution
167
240
  update_session_state()
@@ -169,19 +242,31 @@ def execute_command(command_data):
169
242
  return result, False
170
243
 
171
244
  except Exception as e:
245
+ traceback.print_exc()
172
246
  return {
173
247
  "id": command_data.get("id", "unknown"),
174
248
  "success": False,
175
- "output": f"{SCRIPT_ERROR_MARKER}: Command execution failed",
176
- "error": str(e)
249
+ "output": "{0}: Command execution failed".format(SCRIPT_ERROR_MARKER),
250
+ "error": "{0}\n{1}".format(str(e), traceback.format_exc())
177
251
  }, False
178
252
 
179
253
  def check_command():
180
254
  """Check if there is a new command to execute"""
181
255
  try:
256
+ # If command file doesn't exist, return immediately
257
+ if not os.path.exists(COMMAND_FILE):
258
+ return False
259
+
182
260
  # Read the command file
183
- with open(COMMAND_FILE, 'r') as f:
184
- command_data = json.load(f)
261
+ try:
262
+ with open(COMMAND_FILE, 'r') as f:
263
+ command_data = json.load(f)
264
+ except json.JSONDecodeError:
265
+ # If command file is corrupted, reset it
266
+ command_data = {"status": "ready", "command": None, "timestamp": time.time()}
267
+ with open(COMMAND_FILE, 'w') as f:
268
+ json.dump(command_data, f, indent=2)
269
+ return False
185
270
 
186
271
  # Check if there's a new command
187
272
  if command_data.get("status") == "pending" and command_data.get("command"):
@@ -191,7 +276,10 @@ def check_command():
191
276
  # Mark as processing
192
277
  command_data["status"] = "processing"
193
278
  with open(COMMAND_FILE, 'w') as f:
194
- json.dump(command_data, f)
279
+ json.dump(command_data, f, indent=2)
280
+
281
+ # Update session state to busy
282
+ update_session_state("busy")
195
283
 
196
284
  # Execute the command
197
285
  result, stop_requested = execute_command(command)
@@ -204,7 +292,7 @@ def check_command():
204
292
  }
205
293
 
206
294
  with open(RESULT_FILE, 'w') as f:
207
- json.dump(result_data, f)
295
+ json.dump(result_data, f, indent=2)
208
296
 
209
297
  # Reset command file
210
298
  command_data = {
@@ -214,22 +302,64 @@ def check_command():
214
302
  }
215
303
 
216
304
  with open(COMMAND_FILE, 'w') as f:
217
- json.dump(command_data, f)
305
+ json.dump(command_data, f, indent=2)
306
+
307
+ # Update session state to running again
308
+ if not stop_requested:
309
+ update_session_state("running")
310
+
311
+ # Update last activity time
312
+ update_last_activity()
218
313
 
219
314
  return stop_requested
220
315
 
221
316
  return False
222
317
  except Exception as e:
223
- print(f"Error checking command: {e}")
318
+ print("Error checking command: {0}".format(e))
319
+ traceback.print_exc()
320
+ return False
321
+
322
+ def update_last_activity():
323
+ """Update the last activity timestamp in the session state"""
324
+ try:
325
+ if os.path.exists(SESSION_STATE_FILE):
326
+ with open(SESSION_STATE_FILE, 'r') as f:
327
+ state = json.load(f)
328
+
329
+ state["last_activity"] = time.time()
330
+
331
+ with open(SESSION_STATE_FILE, 'w') as f:
332
+ json.dump(state, f, indent=2)
333
+ except Exception as e:
334
+ print("Error updating last activity: {0}".format(e))
335
+
336
+ def check_inactivity_timeout():
337
+ """Check if session has been inactive for too long"""
338
+ try:
339
+ if os.path.exists(SESSION_STATE_FILE):
340
+ with open(SESSION_STATE_FILE, 'r') as f:
341
+ state = json.load(f)
342
+
343
+ last_activity = state.get("last_activity", state.get("start_time", time.time()))
344
+ current_time = time.time()
345
+
346
+ if (current_time - last_activity) > MAX_INACTIVITY_TIME:
347
+ print("Session inactive for {0:.1f} seconds, shutting down".format(current_time - last_activity))
348
+ return True
349
+
350
+ return False
351
+ except Exception as e:
352
+ print("Error checking inactivity timeout: {0}".format(e))
224
353
  return False
225
354
 
226
355
  def command_loop():
227
356
  """Main loop to check for and execute commands"""
228
357
  stop_requested = False
358
+ last_state_update = time.time()
229
359
 
230
- print(f"Starting command loop...")
231
- print(f"Command file: {COMMAND_FILE}")
232
- print(f"Result file: {RESULT_FILE}")
360
+ print("Starting command loop...")
361
+ print("Command file: {0}".format(COMMAND_FILE))
362
+ print("Result file: {0}".format(RESULT_FILE))
233
363
 
234
364
  while not stop_requested:
235
365
  try:
@@ -240,11 +370,24 @@ def command_loop():
240
370
  # Check for new commands
241
371
  stop_requested = check_command()
242
372
 
373
+ # Check for inactivity timeout
374
+ if check_inactivity_timeout():
375
+ print("Session timeout due to inactivity")
376
+ update_session_state("timeout")
377
+ stop_requested = True
378
+
379
+ # Periodically update session state (every 30 seconds)
380
+ current_time = time.time()
381
+ if (current_time - last_state_update) > 30:
382
+ update_session_state()
383
+ last_state_update = current_time
384
+
243
385
  # Sleep for a bit
244
386
  time.sleep(POLL_INTERVAL)
245
387
 
246
388
  except Exception as e:
247
- print(f"Error in command loop: {e}")
389
+ print("Error in command loop: {0}".format(e))
390
+ traceback.print_exc()
248
391
  # Sleep for a bit
249
392
  time.sleep(POLL_INTERVAL)
250
393
 
@@ -261,17 +404,18 @@ def main():
261
404
  update_session_state()
262
405
 
263
406
  # Start command loop
264
- print(f"{SCRIPT_SUCCESS_MARKER}: CODESYS session started")
407
+ print("{0}: CODESYS session started".format(SCRIPT_SUCCESS_MARKER))
265
408
  command_loop()
266
409
 
267
410
  # Update final state
268
411
  update_session_state("stopped")
269
412
 
270
- print(f"{SCRIPT_SUCCESS_MARKER}: CODESYS session stopped cleanly")
413
+ print("{0}: CODESYS session stopped cleanly".format(SCRIPT_SUCCESS_MARKER))
271
414
  sys.exit(0)
272
415
  except Exception as e:
273
- print(f"Error in session: {e}")
274
- print(f"{SCRIPT_ERROR_MARKER}: Session failed - {str(e)}")
416
+ print("Error in session: {0}".format(e))
417
+ print("{0}: Session failed - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
418
+ traceback.print_exc()
275
419
  sys.exit(1)
276
420
 
277
421
  if __name__ == "__main__":
@@ -1,6 +1,13 @@
1
- import sys, os, json, time
1
+ import sys, os, time, traceback
2
2
  import tempfile
3
3
 
4
+ # Import JSON handling for IronPython 2.7 compatibility
5
+ try:
6
+ import json
7
+ except ImportError:
8
+ # For IronPython 2.7, we need to use simplejson instead
9
+ import simplejson as json
10
+
4
11
  # Define markers for script results
5
12
  SCRIPT_SUCCESS_MARKER = "SCRIPT_SUCCESS"
6
13
  SCRIPT_ERROR_MARKER = "SCRIPT_ERROR"
@@ -17,40 +24,53 @@ def stop_session():
17
24
  try:
18
25
  # Check if session directory exists
19
26
  if not os.path.exists(SESSION_DIR):
20
- print(f"{SCRIPT_ERROR_MARKER}: No session directory found at {SESSION_DIR}")
27
+ print("{0}: No session directory found at {1}".format(SCRIPT_ERROR_MARKER, SESSION_DIR))
21
28
  return False
22
29
 
23
30
  # Check if session state file exists
24
31
  if not os.path.exists(SESSION_STATE_FILE):
25
- print(f"{SCRIPT_ERROR_MARKER}: No session state file found at {SESSION_STATE_FILE}")
32
+ print("{0}: No session state file found at {1}".format(SCRIPT_ERROR_MARKER, SESSION_STATE_FILE))
26
33
  return False
27
34
 
28
35
  # Read session state
29
- with open(SESSION_STATE_FILE, 'r') as f:
30
- try:
36
+ try:
37
+ with open(SESSION_STATE_FILE, 'r') as f:
31
38
  session_state = json.load(f)
32
- except json.JSONDecodeError:
33
- print(f"{SCRIPT_ERROR_MARKER}: Invalid session state file")
34
- return False
39
+ except json.JSONDecodeError:
40
+ print("{0}: Invalid session state file".format(SCRIPT_ERROR_MARKER))
41
+ return False
35
42
 
36
43
  # Check if session is active
37
- if not session_state.get('active', False):
38
- print(f"{SCRIPT_SUCCESS_MARKER}: Session is already stopped")
44
+ if not session_state.get('active', False) or session_state.get('status') == 'stopped':
45
+ print("{0}: Session is already stopped".format(SCRIPT_SUCCESS_MARKER))
39
46
  return True
40
47
 
41
48
  # Check if command file exists
42
49
  if not os.path.exists(COMMAND_FILE):
43
- print(f"{SCRIPT_ERROR_MARKER}: Command file not found at {COMMAND_FILE}")
50
+ print("{0}: Command file not found at {1}".format(SCRIPT_ERROR_MARKER, COMMAND_FILE))
44
51
  return False
45
52
 
53
+ # Try to read the lock file to check if session is still alive
54
+ lock_time = 0
55
+ try:
56
+ if os.path.exists(SESSION_LOCK_FILE):
57
+ with open(SESSION_LOCK_FILE, 'r') as f:
58
+ lock_time = float(f.read().strip())
59
+
60
+ current_time = time.time()
61
+ if (current_time - lock_time) > 30:
62
+ print("WARNING: Session lock file is stale ({0:.1f} seconds old). Session may have crashed.".format(current_time - lock_time))
63
+ except Exception as e:
64
+ print("Warning: Error reading lock file: {0}".format(e))
65
+
46
66
  # Create stop command
47
67
  stop_command = {
48
- "id": f"stop_{int(time.time())}",
68
+ "id": "stop_{0}".format(int(time.time())),
49
69
  "type": "stop",
50
70
  "timestamp": time.time()
51
71
  }
52
72
 
53
- # Write command to file
73
+ # Write command to file with pretty-printing
54
74
  command_data = {
55
75
  "status": "pending",
56
76
  "command": stop_command,
@@ -58,12 +78,12 @@ def stop_session():
58
78
  }
59
79
 
60
80
  with open(COMMAND_FILE, 'w') as f:
61
- json.dump(command_data, f)
81
+ json.dump(command_data, f, indent=2)
62
82
 
63
- print(f"Stop command sent to session")
83
+ print("Stop command sent to session")
64
84
 
65
85
  # Wait for result (with timeout)
66
- timeout = 10 # seconds
86
+ timeout = 15 # seconds (increased timeout for more reliability)
67
87
  start_time = time.time()
68
88
  result_received = False
69
89
 
@@ -81,32 +101,52 @@ def stop_session():
81
101
  result = result_data.get("result")
82
102
  if result.get('id') == stop_command['id']:
83
103
  result_received = True
84
- print(f"Session stop result: {result.get('output', 'No output')}")
104
+ print("Session stop result: {0}".format(result.get('output', 'No output')))
85
105
  break
86
106
  except Exception as e:
87
- print(f"Error reading result: {e}")
107
+ print("Error reading result: {0}".format(e))
88
108
  time.sleep(0.5)
89
109
 
90
- # Update session state to indicate it's stopped
91
- with open(SESSION_STATE_FILE, 'r') as f:
92
- session_state = json.load(f)
93
-
94
- session_state['active'] = False
95
- session_state['status'] = "stopped"
96
- session_state['end_time'] = time.time()
110
+ # Update session state to indicate it's stopped regardless of result
111
+ try:
112
+ with open(SESSION_STATE_FILE, 'r') as f:
113
+ session_state = json.load(f)
114
+
115
+ session_state['active'] = False
116
+ session_state['status'] = "stopped"
117
+ session_state['end_time'] = time.time()
97
118
 
98
- with open(SESSION_STATE_FILE, 'w') as f:
99
- json.dump(session_state, f)
119
+ with open(SESSION_STATE_FILE, 'w') as f:
120
+ json.dump(session_state, f, indent=2)
121
+ except Exception as e:
122
+ print("Warning: Failed to update session state file: {0}".format(e))
100
123
 
101
124
  if result_received:
102
- print(f"{SCRIPT_SUCCESS_MARKER}: Session stopped successfully")
125
+ print("{0}: Session stopped successfully".format(SCRIPT_SUCCESS_MARKER))
126
+ return True
103
127
  else:
104
- print(f"{SCRIPT_ERROR_MARKER}: Timeout waiting for session to stop")
105
-
106
- return result_received
128
+ # If we didn't get a response, the session may be hung
129
+ # Force a cleanup by removing session files
130
+ print("Warning: Session did not respond to stop command. Forcing cleanup.")
131
+ try:
132
+ # Try to remove all session files to ensure a clean state for next session
133
+ for filename in os.listdir(SESSION_DIR):
134
+ filepath = os.path.join(SESSION_DIR, filename)
135
+ try:
136
+ if os.path.isfile(filepath):
137
+ os.unlink(filepath)
138
+ except Exception as e:
139
+ print("Error removing file {0}: {1}".format(filepath, e))
140
+
141
+ print("{0}: Session files cleaned up forcefully".format(SCRIPT_SUCCESS_MARKER))
142
+ return True
143
+ except Exception as e:
144
+ print("{0}: Failed to force cleanup - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
145
+ return False
107
146
 
108
147
  except Exception as e:
109
- print(f"{SCRIPT_ERROR_MARKER}: Error stopping session - {str(e)}")
148
+ print("{0}: Error stopping session - {1}".format(SCRIPT_ERROR_MARKER, str(e)))
149
+ traceback.print_exc()
110
150
  return False
111
151
 
112
152
  # Main script
@@ -1,89 +1,110 @@
1
- # CODESYS MCP Session Management (Not Yet Implemented)
2
-
3
- > **IMPORTANT:** Session management is currently disabled in version 1.2.4.
4
- > The `start_session` and `stop_session` commands exist but are non-functional.
5
- > All operations will run in standard mode (starting and stopping CODESYS for each operation).
6
- > This feature will be fully implemented in a future release.
1
+ # Session Management in CODESYS MCP Toolkit
7
2
 
8
3
  ## Overview
9
4
 
10
- The CODESYS MCP Toolkit now supports persistent sessions, which significantly improves performance when executing multiple operations in sequence. This document explains how to use the session management functionality.
11
-
12
- ## Why Session Management?
5
+ Starting from version 1.4.0, the CODESYS MCP Toolkit supports persistent sessions. This feature significantly improves performance by allowing a single instance of CODESYS to stay open while multiple commands are executed, then closing it when explicitly requested or after a period of inactivity.
13
6
 
14
- Previously, each MCP command would start a new CODESYS process, execute the operation, and then shut down the process. This approach is inefficient when performing multiple operations, such as:
7
+ ## Benefits
15
8
 
16
- - Creating multiple POUs
17
- - Editing multiple code sections
18
- - Complex project setup with many steps
9
+ - **Faster execution**: Eliminates the overhead of starting and stopping CODESYS for each command
10
+ - **Shared context**: Commands executed in the same session share the same CODESYS runtime context
11
+ - **Resource efficiency**: Reduces CPU and memory usage by avoiding repeated process creation
12
+ - **Automatic timeout**: Sessions automatically shut down after a period of inactivity (10 minutes by default)
19
13
 
20
- With session management, you can:
21
- 1. Start a CODESYS session once
22
- 2. Execute multiple operations without restarting CODESYS
23
- 3. Close the session when you're finished
14
+ ## Session Management Commands
24
15
 
25
- This can reduce execution time from minutes to seconds for complex tasks.
16
+ The toolkit provides three specific commands for managing sessions:
26
17
 
27
- ## Using Session Management with Claude
18
+ ### 1. `start_session`
28
19
 
29
- You can now use the `start_session` and `stop_session` commands in your Claude interactions with CODESYS:
30
-
31
- ### Starting a Session
20
+ Starts a new persistent CODESYS session. If a session is already running, this command reports that fact without starting another.
32
21
 
22
+ **Example**:
33
23
  ```
34
- Using the CODESYS Local server, run the start_session command to open a persistent CODESYS session.
24
+ /mcp codesys_local start_session
35
25
  ```
36
26
 
37
- ### Using the Session
27
+ ### 2. `session_status`
38
28
 
39
- Once the session is started, all subsequent operations will use the persistent session automatically:
29
+ Checks if a session is currently active and returns information about it, including:
30
+ - Current state (running, busy, stopped, etc.)
31
+ - How long the session has been running
32
+ - Which project is open in the session (if any)
40
33
 
34
+ **Example**:
41
35
  ```
42
- Using the CODESYS Local server, create a new project at C:/MyProjects/Test.project
36
+ /mcp codesys_local session_status
43
37
  ```
44
38
 
45
- ```
46
- Using the CODESYS Local server, add a POU named MainProgram of type Program with ST language
47
- ```
39
+ ### 3. `stop_session`
48
40
 
49
- ### Checking Session Status
41
+ Stops an active session and cleans up session resources. If no session is running, this command reports that fact without taking any action.
50
42
 
43
+ **Example**:
51
44
  ```
52
- Using the CODESYS Local server, run the session_status command to check the current state of the CODESYS session.
45
+ /mcp codesys_local stop_session
53
46
  ```
54
47
 
55
- ### Stopping the Session
48
+ ## How to Use Session Management
56
49
 
57
- When you're done with all operations, you should close the session to free up resources:
50
+ ### Recommended Workflow
58
51
 
59
- ```
60
- Using the CODESYS Local server, run the stop_session command to close the CODESYS session.
61
- ```
52
+ 1. Start a session at the beginning of your work:
53
+ ```
54
+ /mcp codesys_local start_session
55
+ ```
56
+
57
+ 2. Execute all your commands normally - they will automatically use the active session:
58
+ ```
59
+ /mcp codesys_local open_project --filePath "C:/path/to/project.project"
60
+ /mcp codesys_local create_pou --projectFilePath "C:/path/to/project.project" --name "MyPOU" --type "FunctionBlock" --language "ST" --parentPath "Application"
61
+ ```
62
62
 
63
- ## Recommended Workflow
63
+ 3. When you're done working, stop the session:
64
+ ```
65
+ /mcp codesys_local stop_session
66
+ ```
64
67
 
65
- For the best performance, we recommend the following workflow when working with CODESYS projects:
68
+ If you forget to stop the session, it will automatically time out after 10 minutes of inactivity.
66
69
 
67
- 1. Start the session: `Using the CODESYS Local server, run the start_session command to open a persistent CODESYS session.`
68
- 2. Execute all required operations (create project, create POUs, edit code, etc.)
69
- 3. Stop the session: `Using the CODESYS Local server, run the stop_session command to close the CODESYS session.`
70
+ ### Session Behavior
70
71
 
71
- ## Technical Notes
72
+ - All commands automatically use an active session if one exists
73
+ - If no session exists when a command is run, it will execute without a session (traditional mode)
74
+ - You can explicitly start a session before running commands to improve performance
75
+ - Sessions maintain state between commands, including open projects
72
76
 
73
- - Sessions will automatically close after 5 minutes of inactivity to prevent resource leaks
74
- - The session status can be in one of the following states: `IDLE`, `STARTING`, `RUNNING`, `BUSY`, `STOPPING`, `STOPPED`, or `ERROR`
75
- - If a session operation fails, the system will automatically fall back to the standard one-time execution mode
77
+ ## Troubleshooting
76
78
 
77
- ## Limitations
79
+ ### Common Issues
78
80
 
79
- - The session management is still experimental and may not work with all CODESYS operations
80
- - Only one session can be active at a time per MCP server instance
81
- - Some operations that modify the CODESYS environment itself may require restarting the session
81
+ #### Session Won't Start
82
82
 
83
- ## Troubleshooting
83
+ If you can't start a session, check:
84
+ - Make sure CODESYS is properly installed and accessible at the path specified in your configuration
85
+ - Verify that the CODESYS profile exists and is valid
86
+ - Check if there are any error messages in the client's output
87
+
88
+ #### Session Appears Unresponsive
89
+
90
+ If a session seems stuck:
91
+ 1. Check its status: `/mcp codesys_local session_status`
92
+ 2. If it reports as active but not responding, try stopping it: `/mcp codesys_local stop_session`
93
+ 3. If it still doesn't respond, the toolkit includes a forced cleanup mechanism that will activate automatically when you try to stop a non-responsive session
94
+
95
+ #### Session Files Remain After Stopping
96
+
97
+ Session state is stored in a temporary directory (`[temp]/codesys_session/`). These files should be cleaned up automatically when a session stops, but in rare cases (like if the process is killed abruptly), they might remain. The toolkit will clean up old files when starting a new session.
98
+
99
+ ## Technical Details
100
+
101
+ The session management system works by:
84
102
 
85
- If you encounter issues with session management:
103
+ 1. Starting a CODESYS instance with a special Python script that enters a command loop
104
+ 2. Maintaining session state in files within a temporary directory
105
+ 3. Communicating with the running session via file-based IPC (Inter-Process Communication)
106
+ 4. Executing scripts within the context of the running CODESYS instance
107
+ 5. Automatically detecting and handling session health issues
86
108
 
87
- 1. Check the session status: `Using the CODESYS Local server, run the session_status command`
88
- 2. If the status is `ERROR`, try stopping the session and starting a new one
89
- 3. If problems persist, fall back to using standard operations without explicit session management
109
+ Session files are stored in the system's temporary directory in a subfolder called `codesys_session`.
110
+ EOF < /dev/null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codesys/mcp-toolkit",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Model Context Protocol (MCP) server for CODESYS automation platform",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
@@ -0,0 +1,16 @@
1
+ f"{SCRIPT_ERROR_MARKER}: Invalid session state file" | "{0}: Invalid session state file".format(SCRIPT_ERROR_MARKER)
2
+ f"{SCRIPT_SUCCESS_MARKER}: Session is not active (Status: {status})" | "{0}: Session is not active (Status: {1})".format(SCRIPT_SUCCESS_MARKER, status)
3
+ f"WARNING: Session lock file is stale by {time_diff:.1f} seconds" | "WARNING: Session lock file is stale by {0:.1f} seconds".format(time_diff)
4
+ f"Error reading lock file: {e}" | "Error reading result: {0}".format(e)
5
+ f"ping_{int(time.time())}" | "ping_{0}".format(int(time.time()))
6
+ f"WARNING: Session did not respond to ping within {timeout} seconds" | "WARNING: Session did not respond to ping within {0} seconds".format(timeout)
7
+ f"Error resetting command file: {e}" | "Error resetting command file: {0}".format(e)
8
+ f"Error during ping test: {e}" | "Error during ping test: {0}".format(e)
9
+ f", Project: {project_name} ({project_path})" | ", Project: {0} ({1})".format(project_name, project_path)
10
+ f"{uptime:.0f} seconds" | "{0:.0f} seconds".format(uptime)
11
+ f"{uptime/60:.1f} minutes" | "{0:.1f} minutes".format(uptime/60)
12
+ f"{SCRIPT_SUCCESS_MARKER}: Session is active (ID: {session_id}, Status: {status}, Uptime: {uptime_str}{project_info})" | "{0}: Session is active (ID: {1}, Status: {2}, Uptime: {3}{4})".format(SCRIPT_SUCCESS_MARKER, session_id, status, uptime_str, project_info)
13
+ f"{SCRIPT_SUCCESS_MARKER}: Session appears to be zombie (ID: {session_id}, Status: {status} but unresponsive)" | "{0}: Session appears to be zombie (ID: {1}, Status: {2} but unresponsive)".format(SCRIPT_SUCCESS_MARKER, session_id, status)
14
+ f"Error updating session state: {e}" | "Error updating session state: {0}".format(e)
15
+ f"{SCRIPT_SUCCESS_MARKER}: Session is not active (Status: {status})" | "{0}: Session is not active (Status: {1})".format(SCRIPT_SUCCESS_MARKER, status)
16
+ f"{SCRIPT_ERROR_MARKER}: Error checking session status - {str(e)}" | "{0}: Error checking session status - {1}".format(SCRIPT_ERROR_MARKER, str(e))
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script to fix f-strings in Python files to make them compatible with IronPython 2.7.
4
+ Converts f-strings to str.format() calls.
5
+ """
6
+
7
+ import re
8
+ import sys
9
+ import os
10
+
11
+ def process_file(filepath):
12
+ print(f"Processing {filepath}")
13
+ with open(filepath, 'r', encoding='utf-8') as f:
14
+ content = f.read()
15
+
16
+ # Find and replace f-strings
17
+ # This is a simplified approach - complex nested expressions might need manual review
18
+ pattern = r'f"([^"]*\{[^}]+\}[^"]*)"'
19
+
20
+ # Count potential matches
21
+ matches = re.findall(pattern, content)
22
+ print(f"Found {len(matches)} potential f-strings")
23
+
24
+ # Function to convert each match
25
+ def replace_fstring(match):
26
+ template_str = match.group(1)
27
+ # Replace {var} with {0}, {1}, etc.
28
+ var_pattern = r'\{([^}:]+)(?::([^}]+))?\}'
29
+ var_matches = re.finditer(var_pattern, template_str)
30
+
31
+ format_args = []
32
+ i = 0
33
+
34
+ new_template = template_str
35
+ for m in var_matches:
36
+ var_name = m.group(1)
37
+ format_spec = m.group(2)
38
+
39
+ if format_spec:
40
+ repl = "{" + str(i) + ":" + format_spec + "}"
41
+ else:
42
+ repl = "{" + str(i) + "}"
43
+
44
+ # Store the argument
45
+ format_args.append(var_name)
46
+
47
+ # Replace in the template
48
+ new_template = new_template.replace(m.group(0), repl, 1)
49
+ i += 1
50
+
51
+ # Build the complete format expression
52
+ args_str = ", ".join(format_args)
53
+ return f'"{new_template}".format({args_str})'
54
+
55
+ # Apply the replacements
56
+ new_content = re.sub(pattern, replace_fstring, content)
57
+
58
+ # Write the changes back
59
+ with open(filepath, 'w', encoding='utf-8') as f:
60
+ f.write(new_content)
61
+
62
+ print(f"Completed processing {filepath}")
63
+
64
+ def main():
65
+ if len(sys.argv) < 2:
66
+ print("Usage: python fix_fstrings.py <file_paths...>")
67
+ sys.exit(1)
68
+
69
+ for file_path in sys.argv[1:]:
70
+ if os.path.exists(file_path):
71
+ process_file(file_path)
72
+ else:
73
+ print(f"Error: File {file_path} not found")
74
+
75
+ if __name__ == "__main__":
76
+ main()