pomera-ai-commander 0.1.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.
Files changed (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +680 -0
  3. package/bin/pomera-ai-commander.js +62 -0
  4. package/core/__init__.py +66 -0
  5. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  7. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  8. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  9. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  10. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  11. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  12. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  13. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  14. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  15. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  16. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  17. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  18. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  19. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  20. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  21. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  22. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  23. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  24. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  25. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  26. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  27. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  28. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  29. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  30. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  31. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  32. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  33. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  34. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  35. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  36. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  37. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  38. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  39. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  40. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  41. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  42. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  43. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  44. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  45. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  46. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  47. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  48. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  49. package/core/app_context.py +482 -0
  50. package/core/async_text_processor.py +422 -0
  51. package/core/backup_manager.py +656 -0
  52. package/core/backup_recovery_manager.py +1034 -0
  53. package/core/content_hash_cache.py +509 -0
  54. package/core/context_menu.py +313 -0
  55. package/core/data_validator.py +1067 -0
  56. package/core/database_connection_manager.py +745 -0
  57. package/core/database_curl_settings_manager.py +609 -0
  58. package/core/database_promera_ai_settings_manager.py +447 -0
  59. package/core/database_schema.py +412 -0
  60. package/core/database_schema_manager.py +396 -0
  61. package/core/database_settings_manager.py +1508 -0
  62. package/core/database_settings_manager_interface.py +457 -0
  63. package/core/dialog_manager.py +735 -0
  64. package/core/efficient_line_numbers.py +511 -0
  65. package/core/error_handler.py +747 -0
  66. package/core/error_service.py +431 -0
  67. package/core/event_consolidator.py +512 -0
  68. package/core/mcp/__init__.py +43 -0
  69. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  70. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  71. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  72. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  73. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  74. package/core/mcp/protocol.py +288 -0
  75. package/core/mcp/schema.py +251 -0
  76. package/core/mcp/server_stdio.py +299 -0
  77. package/core/mcp/tool_registry.py +2345 -0
  78. package/core/memory_efficient_text_widget.py +712 -0
  79. package/core/migration_manager.py +915 -0
  80. package/core/migration_test_suite.py +1086 -0
  81. package/core/migration_validator.py +1144 -0
  82. package/core/optimized_find_replace.py +715 -0
  83. package/core/optimized_pattern_engine.py +424 -0
  84. package/core/optimized_search_highlighter.py +553 -0
  85. package/core/performance_monitor.py +675 -0
  86. package/core/persistence_manager.py +713 -0
  87. package/core/progressive_stats_calculator.py +632 -0
  88. package/core/regex_pattern_cache.py +530 -0
  89. package/core/regex_pattern_library.py +351 -0
  90. package/core/search_operation_manager.py +435 -0
  91. package/core/settings_defaults_registry.py +1087 -0
  92. package/core/settings_integrity_validator.py +1112 -0
  93. package/core/settings_serializer.py +558 -0
  94. package/core/settings_validator.py +1824 -0
  95. package/core/smart_stats_calculator.py +710 -0
  96. package/core/statistics_update_manager.py +619 -0
  97. package/core/stats_config_manager.py +858 -0
  98. package/core/streaming_text_handler.py +723 -0
  99. package/core/task_scheduler.py +596 -0
  100. package/core/update_pattern_library.py +169 -0
  101. package/core/visibility_monitor.py +596 -0
  102. package/core/widget_cache.py +498 -0
  103. package/mcp.json +61 -0
  104. package/package.json +57 -0
  105. package/pomera.py +7483 -0
  106. package/pomera_mcp_server.py +144 -0
  107. package/tools/__init__.py +5 -0
  108. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  110. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  111. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  112. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  113. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  114. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  115. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  116. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  117. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  118. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  119. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  120. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  121. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  122. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  123. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  124. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  125. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  126. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  127. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  128. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  129. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  130. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  131. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  132. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  133. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  134. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  135. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  136. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  137. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  138. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  139. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  140. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  141. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  142. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  143. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  144. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  145. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  146. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  147. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  148. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  149. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  150. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
  151. package/tools/ai_tools.py +2892 -0
  152. package/tools/ascii_art_generator.py +353 -0
  153. package/tools/base64_tools.py +184 -0
  154. package/tools/base_tool.py +511 -0
  155. package/tools/case_tool.py +309 -0
  156. package/tools/column_tools.py +396 -0
  157. package/tools/cron_tool.py +885 -0
  158. package/tools/curl_history.py +601 -0
  159. package/tools/curl_processor.py +1208 -0
  160. package/tools/curl_settings.py +503 -0
  161. package/tools/curl_tool.py +5467 -0
  162. package/tools/diff_viewer.py +1072 -0
  163. package/tools/email_extraction_tool.py +249 -0
  164. package/tools/email_header_analyzer.py +426 -0
  165. package/tools/extraction_tools.py +250 -0
  166. package/tools/find_replace.py +1751 -0
  167. package/tools/folder_file_reporter.py +1463 -0
  168. package/tools/folder_file_reporter_adapter.py +480 -0
  169. package/tools/generator_tools.py +1217 -0
  170. package/tools/hash_generator.py +256 -0
  171. package/tools/html_tool.py +657 -0
  172. package/tools/huggingface_helper.py +449 -0
  173. package/tools/jsonxml_tool.py +730 -0
  174. package/tools/line_tools.py +419 -0
  175. package/tools/list_comparator.py +720 -0
  176. package/tools/markdown_tools.py +562 -0
  177. package/tools/mcp_widget.py +1417 -0
  178. package/tools/notes_widget.py +973 -0
  179. package/tools/number_base_converter.py +373 -0
  180. package/tools/regex_extractor.py +572 -0
  181. package/tools/slug_generator.py +311 -0
  182. package/tools/sorter_tools.py +459 -0
  183. package/tools/string_escape_tool.py +393 -0
  184. package/tools/text_statistics_tool.py +366 -0
  185. package/tools/text_wrapper.py +431 -0
  186. package/tools/timestamp_converter.py +422 -0
  187. package/tools/tool_loader.py +710 -0
  188. package/tools/translator_tools.py +523 -0
  189. package/tools/url_link_extractor.py +262 -0
  190. package/tools/url_parser.py +205 -0
  191. package/tools/whitespace_tools.py +356 -0
  192. package/tools/word_frequency_counter.py +147 -0
@@ -0,0 +1,1417 @@
1
+ """
2
+ MCP Manager Widget - UI for MCP Server Control
3
+
4
+ This module provides a graphical interface for managing the MCP server,
5
+ allowing users to start/stop the server and monitor its status.
6
+
7
+ Features:
8
+ - Start/Stop MCP server (as detached background process)
9
+ - Detect running server on startup
10
+ - View server status and connection info
11
+ - View available tools
12
+ - Server log display
13
+ - Dynamic path configuration for Claude Desktop/Cursor
14
+ """
15
+
16
+ import tkinter as tk
17
+ from tkinter import ttk, scrolledtext, messagebox
18
+ import threading
19
+ import queue
20
+ import sys
21
+ import os
22
+ import json
23
+ import logging
24
+ import subprocess
25
+ import signal
26
+ import atexit
27
+ from datetime import datetime
28
+ from typing import Optional, Callable
29
+
30
+ # Add project root to path
31
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ if PROJECT_ROOT not in sys.path:
33
+ sys.path.insert(0, PROJECT_ROOT)
34
+
35
+
36
+ # PID file for tracking server process
37
+ PID_FILE = os.path.join(PROJECT_ROOT, ".mcp_server.pid")
38
+
39
+
40
+ def get_pomera_executable_path() -> tuple:
41
+ """
42
+ Get the path to the Pomera executable/script for MCP server mode.
43
+
44
+ Returns:
45
+ Tuple of (command, args) for running the MCP server.
46
+ For frozen exe: ("pomera.exe", ["--mcp-server"])
47
+ For Python: ("python", ["pomera.py", "--mcp-server"])
48
+ """
49
+ if getattr(sys, 'frozen', False):
50
+ # Running as compiled executable
51
+ exe_path = sys.executable
52
+ return (exe_path, ["--mcp-server"])
53
+ else:
54
+ # Running as Python script
55
+ pomera_script = os.path.join(PROJECT_ROOT, "pomera.py")
56
+ return (sys.executable, [pomera_script, "--mcp-server"])
57
+
58
+
59
+ def get_mcp_config_json() -> str:
60
+ """
61
+ Generate MCP configuration JSON with dynamic path.
62
+
63
+ Returns:
64
+ JSON string for claude_desktop_config.json or .cursor/mcp.json
65
+ """
66
+ command, args = get_pomera_executable_path()
67
+
68
+ # Format path for JSON (use forward slashes)
69
+ command = command.replace("\\", "/")
70
+ args = [arg.replace("\\", "/") for arg in args]
71
+
72
+ config = {
73
+ "mcpServers": {
74
+ "pomera": {
75
+ "command": command,
76
+ "args": args
77
+ }
78
+ }
79
+ }
80
+ return json.dumps(config, indent=2)
81
+
82
+
83
+ def read_pid_file() -> Optional[int]:
84
+ """Read the PID from the PID file if it exists."""
85
+ try:
86
+ if os.path.exists(PID_FILE):
87
+ with open(PID_FILE, 'r') as f:
88
+ return int(f.read().strip())
89
+ except (ValueError, IOError):
90
+ pass
91
+ return None
92
+
93
+
94
+ def write_pid_file(pid: int):
95
+ """Write the PID to the PID file."""
96
+ try:
97
+ with open(PID_FILE, 'w') as f:
98
+ f.write(str(pid))
99
+ except IOError as e:
100
+ logging.getLogger(__name__).error(f"Failed to write PID file: {e}")
101
+
102
+
103
+ def remove_pid_file():
104
+ """Remove the PID file."""
105
+ try:
106
+ if os.path.exists(PID_FILE):
107
+ os.remove(PID_FILE)
108
+ except IOError:
109
+ pass
110
+
111
+
112
+ def is_process_running(pid: int) -> bool:
113
+ """Check if a process with the given PID is running."""
114
+ if pid is None:
115
+ return False
116
+ try:
117
+ if sys.platform == "win32":
118
+ # Windows: use tasklist
119
+ result = subprocess.run(
120
+ ["tasklist", "/FI", f"PID eq {pid}"],
121
+ capture_output=True,
122
+ text=True
123
+ )
124
+ return str(pid) in result.stdout
125
+ else:
126
+ # Unix: send signal 0 to check if process exists
127
+ os.kill(pid, 0)
128
+ return True
129
+ except (OSError, subprocess.SubprocessError):
130
+ return False
131
+
132
+
133
+ def find_running_mcp_server() -> Optional[int]:
134
+ """
135
+ Find a running MCP server process.
136
+
137
+ Returns:
138
+ PID of running server, or None if not found
139
+ """
140
+ # First check PID file
141
+ pid = read_pid_file()
142
+ if pid and is_process_running(pid):
143
+ return pid
144
+
145
+ # Clean up stale PID file
146
+ remove_pid_file()
147
+ return None
148
+
149
+
150
+ class MCPServerProcess:
151
+ """Manages the MCP server as a detached subprocess that persists after app close."""
152
+
153
+ def __init__(self, log_queue: queue.Queue, on_started: Callable, on_stopped: Callable):
154
+ self.log_queue = log_queue
155
+ self.on_started = on_started
156
+ self.on_stopped = on_stopped
157
+ self.process: Optional[subprocess.Popen] = None
158
+ self.monitor_thread: Optional[threading.Thread] = None
159
+ self._stopping = False
160
+ self._external_pid: Optional[int] = None # PID of externally started server
161
+
162
+ def check_existing_server(self) -> bool:
163
+ """
164
+ Check if a server is already running.
165
+
166
+ Returns:
167
+ True if server is running, False otherwise
168
+ """
169
+ pid = find_running_mcp_server()
170
+ if pid:
171
+ self._external_pid = pid
172
+ self.log_queue.put(("INFO", f"Found existing MCP server (PID: {pid})"))
173
+ return True
174
+ return False
175
+
176
+ def start(self, detached: bool = False) -> bool:
177
+ """
178
+ Start the MCP server as a subprocess for testing.
179
+
180
+ Args:
181
+ detached: If True, server runs with minimal parent connection.
182
+ Note: stdio MCP servers are designed to be started by MCP clients
183
+ (Claude Desktop, Cursor). This test mode captures logs for debugging.
184
+ """
185
+ # Check if already running
186
+ if self.is_running():
187
+ self.log_queue.put(("WARNING", "Server is already running"))
188
+ return False
189
+
190
+ try:
191
+ command, args = get_pomera_executable_path()
192
+ full_args = [command] + args + ["--debug"]
193
+
194
+ self.log_queue.put(("INFO", f"Starting MCP server: {' '.join(full_args)}"))
195
+
196
+ # Platform-specific process creation
197
+ # Note: We always capture stderr for logging, but stdin/stdout are for MCP protocol
198
+ if sys.platform == "win32":
199
+ creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP
200
+ if detached:
201
+ creation_flags |= subprocess.CREATE_NO_WINDOW
202
+
203
+ self.process = subprocess.Popen(
204
+ full_args,
205
+ stdout=subprocess.PIPE, # Capture MCP responses for logging
206
+ stderr=subprocess.PIPE, # Capture debug logs
207
+ stdin=subprocess.PIPE, # For sending MCP requests (testing)
208
+ creationflags=creation_flags,
209
+ text=True,
210
+ bufsize=1
211
+ )
212
+ else:
213
+ # Unix
214
+ self.process = subprocess.Popen(
215
+ full_args,
216
+ stdout=subprocess.PIPE,
217
+ stderr=subprocess.PIPE,
218
+ stdin=subprocess.PIPE,
219
+ text=True,
220
+ bufsize=1,
221
+ start_new_session=detached
222
+ )
223
+
224
+ self._stopping = False
225
+ self._external_pid = None
226
+
227
+ # Write PID file for tracking
228
+ write_pid_file(self.process.pid)
229
+
230
+ # Start monitoring thread to capture stderr logs
231
+ if self.process.stderr:
232
+ self.monitor_thread = threading.Thread(target=self._monitor_process, daemon=True)
233
+ self.monitor_thread.start()
234
+
235
+ self.log_queue.put(("INFO", f"Server started (PID: {self.process.pid})"))
236
+ self.log_queue.put(("INFO", "Server is running in test mode - logs will be captured"))
237
+ self.log_queue.put(("INFO", "Note: For production use, configure your MCP client to start the server"))
238
+
239
+ self.on_started()
240
+ return True
241
+
242
+ except Exception as e:
243
+ self.log_queue.put(("ERROR", f"Failed to start server: {str(e)}"))
244
+ return False
245
+
246
+ def stop(self):
247
+ """Stop the MCP server subprocess."""
248
+ pid_to_stop = None
249
+
250
+ if self.process and self.process.poll() is None:
251
+ pid_to_stop = self.process.pid
252
+ elif self._external_pid and is_process_running(self._external_pid):
253
+ pid_to_stop = self._external_pid
254
+
255
+ if not pid_to_stop:
256
+ self.log_queue.put(("WARNING", "No server process to stop"))
257
+ return
258
+
259
+ self._stopping = True
260
+ self.log_queue.put(("INFO", f"Stopping MCP server (PID: {pid_to_stop})..."))
261
+
262
+ try:
263
+ if sys.platform == "win32":
264
+ # Windows: use taskkill
265
+ subprocess.run(["taskkill", "/F", "/PID", str(pid_to_stop)],
266
+ capture_output=True)
267
+ else:
268
+ # Unix: send SIGTERM then SIGKILL
269
+ os.kill(pid_to_stop, signal.SIGTERM)
270
+ # Give it a moment to terminate
271
+ import time
272
+ time.sleep(1)
273
+ if is_process_running(pid_to_stop):
274
+ os.kill(pid_to_stop, signal.SIGKILL)
275
+
276
+ self.log_queue.put(("INFO", "Server stopped"))
277
+ except Exception as e:
278
+ self.log_queue.put(("ERROR", f"Error stopping server: {str(e)}"))
279
+ finally:
280
+ self.process = None
281
+ self._external_pid = None
282
+ remove_pid_file()
283
+ self.on_stopped()
284
+
285
+ def _monitor_process(self):
286
+ """Monitor the server process and capture output."""
287
+ if not self.process or not self.process.stderr:
288
+ return
289
+
290
+ # Read stderr for log messages
291
+ try:
292
+ for line in self.process.stderr:
293
+ if self._stopping:
294
+ break
295
+ line = line.strip()
296
+ if line:
297
+ # Parse log level from line if present
298
+ # Format: "2024-12-06 15:33:17,123 - module - LEVEL - message"
299
+ if " - CRITICAL - " in line:
300
+ self.log_queue.put(("CRITICAL", line))
301
+ elif " - ERROR - " in line:
302
+ self.log_queue.put(("ERROR", line))
303
+ elif " - WARNING - " in line:
304
+ self.log_queue.put(("WARNING", line))
305
+ elif " - DEBUG - " in line:
306
+ self.log_queue.put(("DEBUG", line))
307
+ elif " - INFO - " in line:
308
+ self.log_queue.put(("INFO", line))
309
+ else:
310
+ # Default to INFO for unrecognized format
311
+ self.log_queue.put(("INFO", line))
312
+ except Exception as e:
313
+ self.log_queue.put(("ERROR", f"Error reading server output: {e}"))
314
+
315
+ # Check if process ended unexpectedly
316
+ if self.process and not self._stopping:
317
+ return_code = self.process.poll()
318
+ if return_code is not None:
319
+ self.log_queue.put(("WARNING", f"Server process ended (exit code: {return_code})"))
320
+ remove_pid_file()
321
+ self.on_stopped()
322
+
323
+ def is_running(self) -> bool:
324
+ """Check if the server is running (either our process or external)."""
325
+ if self.process is not None and self.process.poll() is None:
326
+ return True
327
+ if self._external_pid and is_process_running(self._external_pid):
328
+ return True
329
+ # Also check PID file
330
+ pid = find_running_mcp_server()
331
+ if pid:
332
+ self._external_pid = pid
333
+ return True
334
+ return False
335
+
336
+ def get_pid(self) -> Optional[int]:
337
+ """Get the PID of the running server."""
338
+ if self.process and self.process.poll() is None:
339
+ return self.process.pid
340
+ if self._external_pid and is_process_running(self._external_pid):
341
+ return self._external_pid
342
+ return find_running_mcp_server()
343
+
344
+
345
+ class MCPManagerWidget(ttk.Frame):
346
+ """
347
+ MCP Manager Widget for controlling the MCP server.
348
+
349
+ Provides UI for:
350
+ - Starting/stopping the server
351
+ - Viewing server status
352
+ - Viewing available tools
353
+ - Displaying server logs
354
+ - Configuration help
355
+ """
356
+
357
+ def __init__(self, parent, app):
358
+ super().__init__(parent)
359
+ self.app = app
360
+ self.logger = app.logger if hasattr(app, 'logger') else logging.getLogger(__name__)
361
+
362
+ # Server state
363
+ self.server_process: Optional[MCPServerProcess] = None
364
+ self.server_running = False
365
+ self.log_queue = queue.Queue()
366
+
367
+ # Tool registry for display
368
+ self.registry = None
369
+ self._load_registry()
370
+
371
+ self.create_widgets()
372
+ self.start_log_polling()
373
+
374
+ # Check for existing server after UI is ready
375
+ self.after(500, self.check_existing_server)
376
+
377
+ def _load_registry(self):
378
+ """Load tool registry for display purposes."""
379
+ try:
380
+ from core.mcp.tool_registry import ToolRegistry
381
+ self.registry = ToolRegistry()
382
+ except Exception as e:
383
+ self.logger.error(f"Failed to load tool registry: {e}")
384
+ self.registry = None
385
+
386
+ def create_widgets(self):
387
+ """Create the widget interface."""
388
+ # Main container with notebook
389
+ self.notebook = ttk.Notebook(self)
390
+ self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
391
+
392
+ # Server Control Tab
393
+ self.control_frame = ttk.Frame(self.notebook)
394
+ self.notebook.add(self.control_frame, text="Server Control")
395
+ self.create_control_tab()
396
+
397
+ # Tools Tab
398
+ self.tools_frame = ttk.Frame(self.notebook)
399
+ self.notebook.add(self.tools_frame, text="Available Tools")
400
+ self.create_tools_tab()
401
+
402
+ # Configuration Tab
403
+ self.config_frame = ttk.Frame(self.notebook)
404
+ self.notebook.add(self.config_frame, text="Configuration")
405
+ self.create_config_tab()
406
+
407
+ # Test Tool Tab
408
+ self.test_frame = ttk.Frame(self.notebook)
409
+ self.notebook.add(self.test_frame, text="Test Tool")
410
+ self.create_test_tab()
411
+
412
+ # Log Tab
413
+ self.log_frame = ttk.Frame(self.notebook)
414
+ self.notebook.add(self.log_frame, text="Server Log")
415
+ self.create_log_tab()
416
+
417
+ def create_control_tab(self):
418
+ """Create the server control tab."""
419
+ # Status frame
420
+ status_frame = ttk.LabelFrame(self.control_frame, text="Server Status", padding=10)
421
+ status_frame.pack(fill=tk.X, padx=5, pady=5)
422
+
423
+ # Status indicator
424
+ status_row = ttk.Frame(status_frame)
425
+ status_row.pack(fill=tk.X, pady=5)
426
+
427
+ ttk.Label(status_row, text="Status:").pack(side=tk.LEFT, padx=(0, 10))
428
+
429
+ self.status_indicator = tk.Canvas(status_row, width=16, height=16, highlightthickness=0)
430
+ self.status_indicator.pack(side=tk.LEFT, padx=(0, 5))
431
+ self._draw_status_indicator(False)
432
+
433
+ self.status_label = ttk.Label(status_row, text="Stopped", font=("", 10, "bold"))
434
+ self.status_label.pack(side=tk.LEFT)
435
+
436
+ # Server info
437
+ info_frame = ttk.Frame(status_frame)
438
+ info_frame.pack(fill=tk.X, pady=5)
439
+
440
+ ttk.Label(info_frame, text="Server:").pack(side=tk.LEFT, padx=(0, 10))
441
+ ttk.Label(info_frame, text="pomera-mcp-server v0.1.0").pack(side=tk.LEFT)
442
+
443
+ tools_frame = ttk.Frame(status_frame)
444
+ tools_frame.pack(fill=tk.X, pady=5)
445
+
446
+ ttk.Label(tools_frame, text="Tools:").pack(side=tk.LEFT, padx=(0, 10))
447
+ tool_count = len(self.registry) if self.registry else 0
448
+ self.tools_count_label = ttk.Label(tools_frame, text=f"{tool_count} available")
449
+ self.tools_count_label.pack(side=tk.LEFT)
450
+
451
+ # PID display
452
+ pid_frame = ttk.Frame(status_frame)
453
+ pid_frame.pack(fill=tk.X, pady=5)
454
+
455
+ ttk.Label(pid_frame, text="Process ID:").pack(side=tk.LEFT, padx=(0, 10))
456
+ self.pid_label = ttk.Label(pid_frame, text="Not running")
457
+ self.pid_label.pack(side=tk.LEFT)
458
+
459
+ # Control buttons
460
+ button_frame = ttk.LabelFrame(self.control_frame, text="Controls", padding=10)
461
+ button_frame.pack(fill=tk.X, padx=5, pady=5)
462
+
463
+ btn_row = ttk.Frame(button_frame)
464
+ btn_row.pack(fill=tk.X)
465
+
466
+ self.start_btn = ttk.Button(btn_row, text="▶ Start Server", command=self.start_server, width=15)
467
+ self.start_btn.pack(side=tk.LEFT, padx=5)
468
+
469
+ self.stop_btn = ttk.Button(btn_row, text="■ Stop Server", command=self.stop_server, width=15, state=tk.DISABLED)
470
+ self.stop_btn.pack(side=tk.LEFT, padx=5)
471
+
472
+ ttk.Button(btn_row, text="🔄 Refresh Tools", command=self.refresh_tools, width=15).pack(side=tk.LEFT, padx=5)
473
+
474
+ # Info note
475
+ note_frame = ttk.LabelFrame(self.control_frame, text="How to Use", padding=10)
476
+ note_frame.pack(fill=tk.X, padx=5, pady=5)
477
+
478
+ note_text = (
479
+ "The MCP server exposes Pomera's text tools via the Model Context Protocol.\n\n"
480
+ "Option 1: Start from here (for testing/debugging)\n"
481
+ " • Click 'Start Server' to test the MCP server\n"
482
+ " • Server logs will be captured in the 'Server Log' tab\n"
483
+ " • Server will stop when Pomera closes (stdio transport limitation)\n\n"
484
+ "Option 2: Configure your AI client (RECOMMENDED for production)\n"
485
+ " • Copy the configuration from the 'Configuration' tab\n"
486
+ " • Add it to your Claude Desktop or Cursor config\n"
487
+ " • The client will start/stop the server automatically as needed\n"
488
+ " • This is the standard way to use MCP stdio servers"
489
+ )
490
+ ttk.Label(note_frame, text=note_text, wraplength=500, justify=tk.LEFT).pack(anchor=tk.W)
491
+
492
+ def create_tools_tab(self):
493
+ """Create the available tools tab."""
494
+ # Use PanedWindow for resizable split between list and details
495
+ paned = ttk.PanedWindow(self.tools_frame, orient=tk.VERTICAL)
496
+ paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
497
+
498
+ # Top pane: Tools list with scrollbar
499
+ list_frame = ttk.Frame(paned)
500
+
501
+ # Treeview for tools
502
+ columns = ("name", "description")
503
+ self.tools_tree = ttk.Treeview(list_frame, columns=columns, show="headings")
504
+
505
+ self.tools_tree.heading("name", text="Tool Name")
506
+ self.tools_tree.heading("description", text="Description")
507
+
508
+ self.tools_tree.column("name", width=200, minwidth=150)
509
+ self.tools_tree.column("description", width=400, minwidth=200)
510
+
511
+ # Scrollbar for treeview
512
+ tree_scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.tools_tree.yview)
513
+ self.tools_tree.configure(yscrollcommand=tree_scrollbar.set)
514
+
515
+ self.tools_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
516
+ tree_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
517
+
518
+ paned.add(list_frame, weight=1)
519
+
520
+ # Populate tools
521
+ self._populate_tools_list()
522
+
523
+ # Bottom pane: Tool details
524
+ details_frame = ttk.LabelFrame(paned, text="Tool Details", padding=10)
525
+
526
+ self.tool_details = scrolledtext.ScrolledText(details_frame, wrap=tk.WORD, state=tk.DISABLED)
527
+ self.tool_details.pack(fill=tk.BOTH, expand=True)
528
+
529
+ paned.add(details_frame, weight=1)
530
+
531
+ # Bind selection
532
+ self.tools_tree.bind("<<TreeviewSelect>>", self._on_tool_select)
533
+
534
+ def create_config_tab(self):
535
+ """Create the configuration tab."""
536
+ # Get dynamic configuration
537
+ command, args = get_pomera_executable_path()
538
+
539
+ # Path selection frame
540
+ path_frame = ttk.LabelFrame(self.config_frame, text="Pomera Path Configuration", padding=10)
541
+ path_frame.pack(fill=tk.X, padx=5, pady=5)
542
+
543
+ # Execution mode label
544
+ if getattr(sys, 'frozen', False):
545
+ mode_text = "Mode: Compiled executable"
546
+ default_path = command
547
+ else:
548
+ mode_text = "Mode: Python script"
549
+ default_path = args[0] if args else os.path.join(PROJECT_ROOT, "pomera.py")
550
+
551
+ ttk.Label(path_frame, text=mode_text, font=("", 9, "bold")).pack(anchor=tk.W)
552
+
553
+ # Path entry row
554
+ path_row = ttk.Frame(path_frame)
555
+ path_row.pack(fill=tk.X, pady=(5, 0))
556
+
557
+ ttk.Label(path_row, text="Path:").pack(side=tk.LEFT, padx=(0, 5))
558
+
559
+ self.path_var = tk.StringVar(value=default_path)
560
+ self.path_entry = ttk.Entry(path_row, textvariable=self.path_var, width=60)
561
+ self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
562
+
563
+ ttk.Button(path_row, text="Select...", command=self._select_pomera_path).pack(side=tk.LEFT, padx=(0, 5))
564
+ ttk.Button(path_row, text="Apply", command=self._apply_path_config).pack(side=tk.LEFT)
565
+
566
+ # Python executable row (only for script mode)
567
+ if not getattr(sys, 'frozen', False):
568
+ python_row = ttk.Frame(path_frame)
569
+ python_row.pack(fill=tk.X, pady=(5, 0))
570
+
571
+ ttk.Label(python_row, text="Python:").pack(side=tk.LEFT, padx=(0, 5))
572
+
573
+ self.python_var = tk.StringVar(value=sys.executable)
574
+ self.python_entry = ttk.Entry(python_row, textvariable=self.python_var, width=60)
575
+ self.python_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
576
+
577
+ ttk.Button(python_row, text="Select...", command=self._select_python_path).pack(side=tk.LEFT)
578
+
579
+ # Claude Desktop config
580
+ claude_frame = ttk.LabelFrame(self.config_frame, text="Claude Desktop Configuration", padding=10)
581
+ claude_frame.pack(fill=tk.X, padx=5, pady=5)
582
+
583
+ ttk.Label(claude_frame, text="Add to claude_desktop_config.json:").pack(anchor=tk.W)
584
+
585
+ self.claude_config = scrolledtext.ScrolledText(claude_frame, height=8, wrap=tk.WORD)
586
+ self.claude_config.pack(fill=tk.X, pady=5)
587
+
588
+ claude_btn_row = ttk.Frame(claude_frame)
589
+ claude_btn_row.pack(fill=tk.X)
590
+ ttk.Button(claude_btn_row, text="Copy to Clipboard",
591
+ command=self._copy_claude_config).pack(side=tk.LEFT)
592
+
593
+ # Cursor config
594
+ cursor_frame = ttk.LabelFrame(self.config_frame, text="Cursor Configuration", padding=10)
595
+ cursor_frame.pack(fill=tk.X, padx=5, pady=5)
596
+
597
+ ttk.Label(cursor_frame, text="Add to .cursor/mcp.json in your project:").pack(anchor=tk.W)
598
+
599
+ self.cursor_config = scrolledtext.ScrolledText(cursor_frame, height=8, wrap=tk.WORD)
600
+ self.cursor_config.pack(fill=tk.X, pady=5)
601
+
602
+ cursor_btn_row = ttk.Frame(cursor_frame)
603
+ cursor_btn_row.pack(fill=tk.X)
604
+ ttk.Button(cursor_btn_row, text="Copy to Clipboard",
605
+ command=self._copy_cursor_config).pack(side=tk.LEFT)
606
+
607
+ # Command line usage
608
+ cli_frame = ttk.LabelFrame(self.config_frame, text="Command Line Usage", padding=10)
609
+ cli_frame.pack(fill=tk.X, padx=5, pady=5)
610
+
611
+ self.cli_config = scrolledtext.ScrolledText(cli_frame, height=6, wrap=tk.WORD)
612
+ self.cli_config.pack(fill=tk.X, pady=5)
613
+
614
+ # Initial population of config text areas
615
+ self._update_config_displays()
616
+
617
+ def _select_pomera_path(self):
618
+ """Open file dialog to select Pomera executable or script."""
619
+ from tkinter import filedialog
620
+
621
+ if getattr(sys, 'frozen', False):
622
+ filetypes = [("Executable", "*.exe"), ("All files", "*.*")]
623
+ title = "Select pomera.exe"
624
+ else:
625
+ filetypes = [("Python script", "*.py"), ("All files", "*.*")]
626
+ title = "Select pomera.py"
627
+
628
+ path = filedialog.askopenfilename(
629
+ title=title,
630
+ filetypes=filetypes,
631
+ initialdir=os.path.dirname(self.path_var.get()) if self.path_var.get() else PROJECT_ROOT
632
+ )
633
+
634
+ if path:
635
+ self.path_var.set(path)
636
+ self._update_config_displays()
637
+
638
+ def _select_python_path(self):
639
+ """Open file dialog to select Python executable."""
640
+ from tkinter import filedialog
641
+
642
+ if sys.platform == "win32":
643
+ filetypes = [("Executable", "*.exe"), ("All files", "*.*")]
644
+ else:
645
+ filetypes = [("All files", "*.*")]
646
+
647
+ path = filedialog.askopenfilename(
648
+ title="Select Python executable",
649
+ filetypes=filetypes,
650
+ initialdir=os.path.dirname(self.python_var.get()) if hasattr(self, 'python_var') and self.python_var.get() else None
651
+ )
652
+
653
+ if path:
654
+ self.python_var.set(path)
655
+ self._update_config_displays()
656
+
657
+ def _apply_path_config(self):
658
+ """Apply the current path configuration and update displays."""
659
+ self._update_config_displays()
660
+ self._add_log("INFO", "Configuration updated with new path")
661
+
662
+ def _generate_config_json(self) -> str:
663
+ """Generate MCP configuration JSON based on current path settings."""
664
+ pomera_path = self.path_var.get().replace("\\", "/")
665
+
666
+ if getattr(sys, 'frozen', False):
667
+ # Compiled executable mode
668
+ config = {
669
+ "mcpServers": {
670
+ "pomera": {
671
+ "command": pomera_path,
672
+ "args": ["--mcp-server"]
673
+ }
674
+ }
675
+ }
676
+ else:
677
+ # Python script mode
678
+ python_path = self.python_var.get().replace("\\", "/") if hasattr(self, 'python_var') else sys.executable.replace("\\", "/")
679
+ config = {
680
+ "mcpServers": {
681
+ "pomera": {
682
+ "command": python_path,
683
+ "args": [pomera_path, "--mcp-server"]
684
+ }
685
+ }
686
+ }
687
+
688
+ return json.dumps(config, indent=2)
689
+
690
+ def _generate_cli_text(self) -> str:
691
+ """Generate CLI usage text based on current path settings."""
692
+ pomera_path = self.path_var.get()
693
+
694
+ if getattr(sys, 'frozen', False):
695
+ return f"""# List available tools:
696
+ "{pomera_path}" --mcp-server --list-tools
697
+
698
+ # Run server (for manual testing):
699
+ "{pomera_path}" --mcp-server
700
+
701
+ # Run with debug logging:
702
+ "{pomera_path}" --mcp-server --debug"""
703
+ else:
704
+ python_path = self.python_var.get() if hasattr(self, 'python_var') else sys.executable
705
+ return f"""# List available tools:
706
+ "{python_path}" "{pomera_path}" --mcp-server --list-tools
707
+
708
+ # Run server (for manual testing):
709
+ "{python_path}" "{pomera_path}" --mcp-server
710
+
711
+ # Run with debug logging:
712
+ "{python_path}" "{pomera_path}" --mcp-server --debug"""
713
+
714
+ def _update_config_displays(self):
715
+ """Update all configuration text displays with current path settings."""
716
+ config_json = self._generate_config_json()
717
+ cli_text = self._generate_cli_text()
718
+
719
+ # Update Claude config
720
+ self.claude_config.config(state=tk.NORMAL)
721
+ self.claude_config.delete("1.0", tk.END)
722
+ self.claude_config.insert(tk.END, config_json)
723
+ self.claude_config.config(state=tk.DISABLED)
724
+
725
+ # Update Cursor config
726
+ self.cursor_config.config(state=tk.NORMAL)
727
+ self.cursor_config.delete("1.0", tk.END)
728
+ self.cursor_config.insert(tk.END, config_json)
729
+ self.cursor_config.config(state=tk.DISABLED)
730
+
731
+ # Update CLI text
732
+ self.cli_config.config(state=tk.NORMAL)
733
+ self.cli_config.delete("1.0", tk.END)
734
+ self.cli_config.insert(tk.END, cli_text)
735
+ self.cli_config.config(state=tk.DISABLED)
736
+
737
+ def _copy_claude_config(self):
738
+ """Copy Claude Desktop config to clipboard."""
739
+ config = self._generate_config_json()
740
+ self._copy_to_clipboard(config)
741
+
742
+ def _copy_cursor_config(self):
743
+ """Copy Cursor config to clipboard."""
744
+ config = self._generate_config_json()
745
+ self._copy_to_clipboard(config)
746
+
747
+ def create_test_tab(self):
748
+ """Create the test tool tab for sending MCP requests."""
749
+ # Tool selection frame
750
+ select_frame = ttk.LabelFrame(self.test_frame, text="Select Tool", padding=10)
751
+ select_frame.pack(fill=tk.X, padx=5, pady=5)
752
+
753
+ # Tool dropdown
754
+ tool_row = ttk.Frame(select_frame)
755
+ tool_row.pack(fill=tk.X)
756
+
757
+ ttk.Label(tool_row, text="Tool:").pack(side=tk.LEFT, padx=(0, 5))
758
+
759
+ self.test_tool_var = tk.StringVar()
760
+ self.test_tool_combo = ttk.Combobox(
761
+ tool_row,
762
+ textvariable=self.test_tool_var,
763
+ state="readonly",
764
+ width=40
765
+ )
766
+ self.test_tool_combo.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
767
+ self.test_tool_combo.bind("<<ComboboxSelected>>", self._on_test_tool_select)
768
+
769
+ # Parameters frame
770
+ params_frame = ttk.LabelFrame(self.test_frame, text="Parameters (JSON)", padding=10)
771
+ params_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
772
+
773
+ # Parameter hints label
774
+ self.param_hints_label = ttk.Label(params_frame, text="Select a tool to see required parameters", foreground="gray")
775
+ self.param_hints_label.pack(anchor=tk.W, pady=(0, 5))
776
+
777
+ # Parameters text area
778
+ self.test_params = scrolledtext.ScrolledText(params_frame, height=8, wrap=tk.WORD)
779
+ self.test_params.pack(fill=tk.BOTH, expand=True)
780
+ self.test_params.insert(tk.END, "{}")
781
+
782
+ # Execute button frame
783
+ exec_frame = ttk.Frame(self.test_frame)
784
+ exec_frame.pack(fill=tk.X, padx=5, pady=5)
785
+
786
+ self.test_execute_btn = ttk.Button(
787
+ exec_frame,
788
+ text="▶ Execute Tool",
789
+ command=self._execute_test_tool,
790
+ width=20
791
+ )
792
+ self.test_execute_btn.pack(side=tk.LEFT)
793
+
794
+ ttk.Button(
795
+ exec_frame,
796
+ text="Clear Result",
797
+ command=self._clear_test_result,
798
+ width=15
799
+ ).pack(side=tk.LEFT, padx=(10, 0))
800
+
801
+ # Server-only checkbox
802
+ self.test_server_only_var = tk.BooleanVar(value=False)
803
+ self.test_server_only_cb = ttk.Checkbutton(
804
+ exec_frame,
805
+ text="Test via server only",
806
+ variable=self.test_server_only_var
807
+ )
808
+ self.test_server_only_cb.pack(side=tk.LEFT, padx=(20, 0))
809
+
810
+ # Status label
811
+ self.test_status_label = ttk.Label(exec_frame, text="")
812
+ self.test_status_label.pack(side=tk.RIGHT)
813
+
814
+ # Result frame
815
+ result_frame = ttk.LabelFrame(self.test_frame, text="Result", padding=10)
816
+ result_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
817
+
818
+ self.test_result = scrolledtext.ScrolledText(result_frame, height=10, wrap=tk.WORD, state=tk.DISABLED)
819
+ self.test_result.pack(fill=tk.BOTH, expand=True)
820
+
821
+ # Configure tags for result display
822
+ self.test_result.tag_configure("success", foreground="green")
823
+ self.test_result.tag_configure("error", foreground="red")
824
+ self.test_result.tag_configure("info", foreground="blue")
825
+
826
+ # Populate tool dropdown (after all widgets are created)
827
+ self._populate_test_tool_combo()
828
+
829
+ def _populate_test_tool_combo(self):
830
+ """Populate the test tool dropdown with available tools."""
831
+ if self.registry:
832
+ tool_names = [tool.name for tool in self.registry.list_tools()]
833
+ self.test_tool_combo['values'] = sorted(tool_names)
834
+ if tool_names:
835
+ self.test_tool_combo.set(sorted(tool_names)[0])
836
+ self._on_test_tool_select(None)
837
+
838
+ def _on_test_tool_select(self, event):
839
+ """Handle tool selection in test tab - show parameter hints."""
840
+ tool_name = self.test_tool_var.get()
841
+ if not tool_name or not self.registry:
842
+ return
843
+
844
+ # Check if UI elements exist yet
845
+ if not hasattr(self, 'param_hints_label') or not hasattr(self, 'test_params'):
846
+ return
847
+
848
+ tool = self.registry.get_tool(tool_name)
849
+ if not tool:
850
+ return
851
+
852
+ mcp_tool = tool.to_mcp_tool()
853
+ schema = mcp_tool.inputSchema
854
+
855
+ # Build parameter hints
856
+ properties = schema.get("properties", {})
857
+ required = schema.get("required", [])
858
+
859
+ hints = []
860
+ for param_name, param_info in properties.items():
861
+ param_type = param_info.get("type", "any")
862
+ param_desc = param_info.get("description", "")
863
+ is_required = param_name in required
864
+ req_marker = "*" if is_required else ""
865
+ hints.append(f" • {param_name}{req_marker} ({param_type}): {param_desc[:50]}...")
866
+
867
+ if hints:
868
+ hint_text = "Parameters (* = required):\n" + "\n".join(hints[:5])
869
+ if len(hints) > 5:
870
+ hint_text += f"\n ... and {len(hints) - 5} more"
871
+ else:
872
+ hint_text = "No parameters required"
873
+
874
+ self.param_hints_label.config(text=hint_text)
875
+
876
+ # Generate example JSON with ALL parameters (required + optional with defaults)
877
+ example = {}
878
+ for param_name, param_info in properties.items():
879
+ param_type = param_info.get("type", "string")
880
+ is_required = param_name in required
881
+
882
+ # Use default value if available, otherwise generate placeholder
883
+ if "default" in param_info:
884
+ example[param_name] = param_info["default"]
885
+ elif "enum" in param_info:
886
+ example[param_name] = param_info["enum"][0]
887
+ elif param_type == "string":
888
+ example[param_name] = f"<{param_name}>" if is_required else ""
889
+ elif param_type == "integer":
890
+ example[param_name] = 0
891
+ elif param_type == "number":
892
+ example[param_name] = 0
893
+ elif param_type == "boolean":
894
+ example[param_name] = False
895
+ else:
896
+ example[param_name] = f"<{param_name}>" if is_required else None
897
+
898
+ self.test_params.delete("1.0", tk.END)
899
+ self.test_params.insert(tk.END, json.dumps(example, indent=2))
900
+
901
+ def _execute_test_tool(self):
902
+ """Execute the selected tool with the provided parameters."""
903
+ tool_name = self.test_tool_var.get()
904
+ if not tool_name:
905
+ self._show_test_error("Please select a tool")
906
+ return
907
+
908
+ # Parse parameters
909
+ try:
910
+ params_text = self.test_params.get("1.0", tk.END).strip()
911
+ params = json.loads(params_text) if params_text else {}
912
+ except json.JSONDecodeError as e:
913
+ self._show_test_error(f"Invalid JSON parameters: {e}")
914
+ return
915
+
916
+ # Check if "server only" mode is enabled
917
+ server_only = self.test_server_only_var.get()
918
+ server_running = self.server_process and self.server_process.is_running() and self.server_process.process
919
+
920
+ if server_only and not server_running:
921
+ self._show_test_error("Server is not running. Start the server first or uncheck 'Test via server only'.")
922
+ return
923
+
924
+ # Check if server is running
925
+ if server_running:
926
+ # Send request to running server via stdin
927
+ self._execute_via_server(tool_name, params)
928
+ else:
929
+ # Execute directly via registry
930
+ self._execute_directly(tool_name, params)
931
+
932
+ def _execute_via_server(self, tool_name: str, params: dict):
933
+ """Execute tool by sending request to the running server's stdin."""
934
+ self.test_status_label.config(text="Sending request...", foreground="blue")
935
+ self.update_idletasks()
936
+
937
+ try:
938
+ # Build MCP request
939
+ request = {
940
+ "jsonrpc": "2.0",
941
+ "id": 1,
942
+ "method": "tools/call",
943
+ "params": {
944
+ "name": tool_name,
945
+ "arguments": params
946
+ }
947
+ }
948
+
949
+ # Need to send initialize first if not already done
950
+ init_request = {
951
+ "jsonrpc": "2.0",
952
+ "id": 0,
953
+ "method": "initialize",
954
+ "params": {
955
+ "protocolVersion": "2024-11-05",
956
+ "capabilities": {},
957
+ "clientInfo": {"name": "pomera-test", "version": "1.0"}
958
+ }
959
+ }
960
+
961
+ # Send requests
962
+ process = self.server_process.process
963
+ if process and process.stdin and process.stdout:
964
+ # Send initialize
965
+ process.stdin.write(json.dumps(init_request) + "\n")
966
+ process.stdin.flush()
967
+
968
+ # Read init response
969
+ init_response = process.stdout.readline()
970
+
971
+ # Send tool call
972
+ process.stdin.write(json.dumps(request) + "\n")
973
+ process.stdin.flush()
974
+
975
+ # Read response with timeout
976
+ response_line = process.stdout.readline()
977
+ if response_line:
978
+ response = json.loads(response_line)
979
+ self._show_test_result(response)
980
+ else:
981
+ self._show_test_error("No response from server")
982
+ else:
983
+ self._show_test_error("Server stdin/stdout not available")
984
+
985
+ except Exception as e:
986
+ self._show_test_error(f"Error communicating with server: {e}")
987
+
988
+ def _execute_directly(self, tool_name: str, params: dict):
989
+ """Execute tool directly via the registry (no server needed)."""
990
+ self.test_status_label.config(text="Executing directly...", foreground="blue")
991
+ self.update_idletasks()
992
+
993
+ if not self.registry:
994
+ self._show_test_error("Tool registry not available")
995
+ return
996
+
997
+ try:
998
+ result = self.registry.execute(tool_name, params)
999
+
1000
+ # Format result
1001
+ response = {
1002
+ "success": not result.isError,
1003
+ "content": result.content
1004
+ }
1005
+ self._show_test_result(response)
1006
+
1007
+ except Exception as e:
1008
+ self._show_test_error(f"Execution error: {e}")
1009
+
1010
+ def _show_test_result(self, response: dict):
1011
+ """Display test result in the result area."""
1012
+ self.test_result.config(state=tk.NORMAL)
1013
+ self.test_result.delete("1.0", tk.END)
1014
+
1015
+ # Check if it's an error response
1016
+ if "error" in response:
1017
+ self.test_status_label.config(text="Error", foreground="red")
1018
+ self.test_result.insert(tk.END, "ERROR:\n", "error")
1019
+ self.test_result.insert(tk.END, json.dumps(response["error"], indent=2))
1020
+ elif response.get("success") == False or response.get("isError"):
1021
+ self.test_status_label.config(text="Tool returned error", foreground="orange")
1022
+ self.test_result.insert(tk.END, "Tool Error:\n", "error")
1023
+ content = response.get("content", response.get("result", {}).get("content", []))
1024
+ if isinstance(content, list):
1025
+ for item in content:
1026
+ if item.get("type") == "text":
1027
+ self.test_result.insert(tk.END, item.get("text", ""))
1028
+ else:
1029
+ self.test_result.insert(tk.END, json.dumps(content, indent=2))
1030
+ else:
1031
+ self.test_status_label.config(text="Success", foreground="green")
1032
+ self.test_result.insert(tk.END, "Result:\n", "success")
1033
+
1034
+ # Extract content from MCP response
1035
+ content = response.get("content", response.get("result", {}).get("content", []))
1036
+ if isinstance(content, list):
1037
+ for item in content:
1038
+ if item.get("type") == "text":
1039
+ self.test_result.insert(tk.END, item.get("text", ""))
1040
+ self.test_result.insert(tk.END, "\n")
1041
+ else:
1042
+ self.test_result.insert(tk.END, json.dumps(content, indent=2))
1043
+
1044
+ self.test_result.config(state=tk.DISABLED)
1045
+ self._add_log("INFO", f"Test executed: {self.test_tool_var.get()}")
1046
+
1047
+ def _show_test_error(self, message: str):
1048
+ """Display an error message in the test result area."""
1049
+ self.test_status_label.config(text="Error", foreground="red")
1050
+ self.test_result.config(state=tk.NORMAL)
1051
+ self.test_result.delete("1.0", tk.END)
1052
+ self.test_result.insert(tk.END, f"Error: {message}", "error")
1053
+ self.test_result.config(state=tk.DISABLED)
1054
+ self._add_log("ERROR", f"Test error: {message}")
1055
+
1056
+ def _clear_test_result(self):
1057
+ """Clear the test result area."""
1058
+ self.test_result.config(state=tk.NORMAL)
1059
+ self.test_result.delete("1.0", tk.END)
1060
+ self.test_result.config(state=tk.DISABLED)
1061
+ self.test_status_label.config(text="")
1062
+
1063
+ def create_log_tab(self):
1064
+ """Create the server log tab."""
1065
+ # Control frame at top
1066
+ control_frame = ttk.Frame(self.log_frame)
1067
+ control_frame.pack(fill=tk.X, padx=5, pady=5)
1068
+
1069
+ # Log level filter
1070
+ ttk.Label(control_frame, text="Log Level:").pack(side=tk.LEFT, padx=(0, 5))
1071
+
1072
+ self.log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
1073
+ self.log_level_var = tk.StringVar(value="DEBUG") # Show all by default
1074
+ self.log_level_combo = ttk.Combobox(
1075
+ control_frame,
1076
+ textvariable=self.log_level_var,
1077
+ values=self.log_levels,
1078
+ state="readonly",
1079
+ width=12
1080
+ )
1081
+ self.log_level_combo.pack(side=tk.LEFT, padx=(0, 10))
1082
+ self.log_level_combo.bind("<<ComboboxSelected>>", self._on_log_level_change)
1083
+
1084
+ # Auto-scroll checkbox
1085
+ self.auto_scroll_var = tk.BooleanVar(value=True)
1086
+ ttk.Checkbutton(
1087
+ control_frame,
1088
+ text="Auto-scroll",
1089
+ variable=self.auto_scroll_var
1090
+ ).pack(side=tk.LEFT, padx=(0, 10))
1091
+
1092
+ # Clear button
1093
+ ttk.Button(control_frame, text="Clear Log", command=self._clear_log).pack(side=tk.LEFT)
1094
+
1095
+ # Log entry count label
1096
+ self.log_count_label = ttk.Label(control_frame, text="Entries: 0")
1097
+ self.log_count_label.pack(side=tk.RIGHT)
1098
+
1099
+ # Log display
1100
+ self.log_text = scrolledtext.ScrolledText(self.log_frame, height=20, wrap=tk.WORD, state=tk.DISABLED)
1101
+ self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
1102
+
1103
+ # Configure tags for log levels with colors
1104
+ self.log_text.tag_configure("DEBUG", foreground="gray")
1105
+ self.log_text.tag_configure("INFO", foreground="green")
1106
+ self.log_text.tag_configure("WARNING", foreground="orange")
1107
+ self.log_text.tag_configure("ERROR", foreground="red")
1108
+ self.log_text.tag_configure("CRITICAL", foreground="red", font=("", 10, "bold"))
1109
+ self.log_text.tag_configure("TIMESTAMP", foreground="blue")
1110
+
1111
+ # Store all log entries for filtering
1112
+ self.all_log_entries = []
1113
+
1114
+ # Initial log message
1115
+ self._add_log("INFO", "MCP Manager initialized. Ready to start server.")
1116
+
1117
+ def _on_log_level_change(self, event=None):
1118
+ """Handle log level filter change - refresh display with filtered entries."""
1119
+ self._refresh_log_display()
1120
+
1121
+ def _get_log_level_priority(self, level: str) -> int:
1122
+ """Get numeric priority for a log level (higher = more severe)."""
1123
+ priorities = {
1124
+ "DEBUG": 0,
1125
+ "INFO": 1,
1126
+ "WARNING": 2,
1127
+ "ERROR": 3,
1128
+ "CRITICAL": 4
1129
+ }
1130
+ return priorities.get(level.upper(), 0)
1131
+
1132
+ def _refresh_log_display(self):
1133
+ """Refresh the log display based on current filter level."""
1134
+ min_level = self._get_log_level_priority(self.log_level_var.get())
1135
+
1136
+ self.log_text.config(state=tk.NORMAL)
1137
+ self.log_text.delete("1.0", tk.END)
1138
+
1139
+ visible_count = 0
1140
+ for timestamp, level, message in self.all_log_entries:
1141
+ if self._get_log_level_priority(level) >= min_level:
1142
+ self.log_text.insert(tk.END, f"[{timestamp}] ", "TIMESTAMP")
1143
+ self.log_text.insert(tk.END, f"[{level}] ", level)
1144
+ self.log_text.insert(tk.END, f"{message}\n")
1145
+ visible_count += 1
1146
+
1147
+ self.log_text.config(state=tk.DISABLED)
1148
+
1149
+ if self.auto_scroll_var.get():
1150
+ self.log_text.see(tk.END)
1151
+
1152
+ # Update count label
1153
+ total = len(self.all_log_entries)
1154
+ if visible_count == total:
1155
+ self.log_count_label.config(text=f"Entries: {total}")
1156
+ else:
1157
+ self.log_count_label.config(text=f"Entries: {visible_count}/{total}")
1158
+
1159
+ def _draw_status_indicator(self, running: bool):
1160
+ """Draw the status indicator circle."""
1161
+ self.status_indicator.delete("all")
1162
+ color = "#00CC00" if running else "#CC0000"
1163
+ self.status_indicator.create_oval(2, 2, 14, 14, fill=color, outline=color)
1164
+
1165
+ def _populate_tools_list(self):
1166
+ """Populate the tools treeview."""
1167
+ # Clear existing
1168
+ for item in self.tools_tree.get_children():
1169
+ self.tools_tree.delete(item)
1170
+
1171
+ if not self.registry:
1172
+ return
1173
+
1174
+ for tool in self.registry.list_tools():
1175
+ # Truncate description for display
1176
+ desc = tool.description[:80] + "..." if len(tool.description) > 80 else tool.description
1177
+ self.tools_tree.insert("", tk.END, values=(tool.name, desc))
1178
+
1179
+ def _on_tool_select(self, event):
1180
+ """Handle tool selection in treeview."""
1181
+ selection = self.tools_tree.selection()
1182
+ if not selection:
1183
+ return
1184
+
1185
+ item = self.tools_tree.item(selection[0])
1186
+ tool_name = item["values"][0]
1187
+
1188
+ if not self.registry:
1189
+ return
1190
+
1191
+ tool = self.registry.get_tool(tool_name)
1192
+ if not tool:
1193
+ return
1194
+
1195
+ # Display tool details in a readable format
1196
+ mcp_tool = tool.to_mcp_tool()
1197
+ schema = mcp_tool.inputSchema
1198
+ properties = schema.get("properties", {})
1199
+ required = schema.get("required", [])
1200
+
1201
+ details = f"Tool: {mcp_tool.name}\n"
1202
+ details += "=" * 60 + "\n\n"
1203
+ details += f"Description:\n{mcp_tool.description}\n\n"
1204
+
1205
+ # Parameters section
1206
+ details += "Parameters:\n"
1207
+ details += "-" * 40 + "\n"
1208
+
1209
+ if properties:
1210
+ for param_name, param_info in properties.items():
1211
+ is_required = param_name in required
1212
+ param_type = param_info.get("type", "any")
1213
+ param_desc = param_info.get("description", "No description")
1214
+
1215
+ # Format parameter header
1216
+ req_marker = " [REQUIRED]" if is_required else " [optional]"
1217
+ details += f"\n• {param_name}{req_marker}\n"
1218
+ details += f" Type: {param_type}\n"
1219
+
1220
+ # Show enum values if present
1221
+ if "enum" in param_info:
1222
+ enum_values = ", ".join(str(v) for v in param_info["enum"])
1223
+ details += f" Options: {enum_values}\n"
1224
+
1225
+ # Show default value if present
1226
+ if "default" in param_info:
1227
+ default_val = param_info["default"]
1228
+ if isinstance(default_val, str) and len(default_val) > 50:
1229
+ default_val = default_val[:50] + "..."
1230
+ details += f" Default: {default_val}\n"
1231
+
1232
+ details += f" Description: {param_desc}\n"
1233
+ else:
1234
+ details += "\n(No parameters)\n"
1235
+
1236
+ self.tool_details.config(state=tk.NORMAL)
1237
+ self.tool_details.delete("1.0", tk.END)
1238
+ self.tool_details.insert(tk.END, details)
1239
+ self.tool_details.config(state=tk.DISABLED)
1240
+
1241
+ def _copy_to_clipboard(self, text: str):
1242
+ """Copy text to clipboard."""
1243
+ self.clipboard_clear()
1244
+ self.clipboard_append(text)
1245
+ self._add_log("INFO", "Configuration copied to clipboard")
1246
+
1247
+ def _add_log(self, level: str, message: str):
1248
+ """Add a log message to the log display."""
1249
+ timestamp = datetime.now().strftime("%H:%M:%S")
1250
+
1251
+ # Store in all entries for filtering
1252
+ if hasattr(self, 'all_log_entries'):
1253
+ self.all_log_entries.append((timestamp, level.upper(), message))
1254
+
1255
+ # Check if this message should be displayed based on current filter
1256
+ min_level = self._get_log_level_priority(self.log_level_var.get()) if hasattr(self, 'log_level_var') else 0
1257
+ if self._get_log_level_priority(level) >= min_level:
1258
+ self.log_text.config(state=tk.NORMAL)
1259
+ self.log_text.insert(tk.END, f"[{timestamp}] ", "TIMESTAMP")
1260
+ self.log_text.insert(tk.END, f"[{level.upper()}] ", level.upper())
1261
+ self.log_text.insert(tk.END, f"{message}\n")
1262
+
1263
+ if hasattr(self, 'auto_scroll_var') and self.auto_scroll_var.get():
1264
+ self.log_text.see(tk.END)
1265
+
1266
+ self.log_text.config(state=tk.DISABLED)
1267
+
1268
+ # Update count label
1269
+ if hasattr(self, 'log_count_label') and hasattr(self, 'all_log_entries'):
1270
+ total = len(self.all_log_entries)
1271
+ visible = sum(1 for _, lvl, _ in self.all_log_entries
1272
+ if self._get_log_level_priority(lvl) >= min_level)
1273
+ if visible == total:
1274
+ self.log_count_label.config(text=f"Entries: {total}")
1275
+ else:
1276
+ self.log_count_label.config(text=f"Entries: {visible}/{total}")
1277
+
1278
+ def _clear_log(self):
1279
+ """Clear the log display."""
1280
+ if hasattr(self, 'all_log_entries'):
1281
+ self.all_log_entries.clear()
1282
+
1283
+ self.log_text.config(state=tk.NORMAL)
1284
+ self.log_text.delete("1.0", tk.END)
1285
+ self.log_text.config(state=tk.DISABLED)
1286
+
1287
+ # Update count label
1288
+ if hasattr(self, 'log_count_label'):
1289
+ self.log_count_label.config(text="Entries: 0")
1290
+
1291
+ self._add_log("INFO", "Log cleared")
1292
+
1293
+ def start_log_polling(self):
1294
+ """Start polling the log queue for messages."""
1295
+ self._poll_log_queue()
1296
+
1297
+ def _poll_log_queue(self):
1298
+ """Poll the log queue and display messages."""
1299
+ try:
1300
+ while True:
1301
+ level, message = self.log_queue.get_nowait()
1302
+ self._add_log(level, message)
1303
+ except queue.Empty:
1304
+ pass
1305
+
1306
+ # Schedule next poll
1307
+ self.after(100, self._poll_log_queue)
1308
+
1309
+ def start_server(self):
1310
+ """Start the MCP server as a detached subprocess."""
1311
+ if self.server_running:
1312
+ self._add_log("WARNING", "Server is already running")
1313
+ return
1314
+
1315
+ # Create server process manager
1316
+ self.server_process = MCPServerProcess(
1317
+ self.log_queue,
1318
+ self._on_server_started,
1319
+ self._on_server_stopped
1320
+ )
1321
+
1322
+ # Start in detached mode so it persists after Pomera closes
1323
+ if self.server_process.start(detached=True):
1324
+ self._add_log("INFO", "MCP server started in detached mode")
1325
+ self._add_log("INFO", "Server will continue running after Pomera closes")
1326
+ self._add_log("INFO", "The server is now ready to accept connections from Claude Desktop or Cursor")
1327
+ else:
1328
+ self._add_log("ERROR", "Failed to start MCP server")
1329
+
1330
+ def check_existing_server(self):
1331
+ """Check if an MCP server is already running and update UI accordingly."""
1332
+ pid = find_running_mcp_server()
1333
+ if pid:
1334
+ self._add_log("INFO", f"Detected existing MCP server (PID: {pid})")
1335
+ # Create process manager to track it
1336
+ self.server_process = MCPServerProcess(
1337
+ self.log_queue,
1338
+ self._on_server_started,
1339
+ self._on_server_stopped
1340
+ )
1341
+ self.server_process._external_pid = pid
1342
+ self._update_status(True)
1343
+ return True
1344
+ return False
1345
+
1346
+ def stop_server(self):
1347
+ """Stop the MCP server subprocess."""
1348
+ if not self.server_running and (not self.server_process or not self.server_process.is_running()):
1349
+ self._add_log("WARNING", "Server is not running")
1350
+ return
1351
+
1352
+ if self.server_process:
1353
+ self.server_process.stop()
1354
+ self.server_process = None
1355
+
1356
+ def _update_status(self, running: bool):
1357
+ """Update the server status display."""
1358
+ self.server_running = running
1359
+ self._draw_status_indicator(running)
1360
+
1361
+ if running:
1362
+ self.status_label.config(text="Running")
1363
+ self.start_btn.config(state=tk.DISABLED)
1364
+ self.stop_btn.config(state=tk.NORMAL)
1365
+ # Update PID from process manager
1366
+ if self.server_process:
1367
+ pid = self.server_process.get_pid()
1368
+ if pid:
1369
+ self.pid_label.config(text=str(pid))
1370
+ else:
1371
+ self.pid_label.config(text="Running (PID unknown)")
1372
+ else:
1373
+ self.pid_label.config(text="Running")
1374
+ else:
1375
+ self.status_label.config(text="Stopped")
1376
+ self.start_btn.config(state=tk.NORMAL)
1377
+ self.stop_btn.config(state=tk.DISABLED)
1378
+ self.pid_label.config(text="Not running")
1379
+
1380
+ def _on_server_started(self):
1381
+ """Callback when server starts."""
1382
+ self.after(0, lambda: self._update_status(True))
1383
+
1384
+ def _on_server_stopped(self):
1385
+ """Callback when server stops."""
1386
+ self.after(0, lambda: self._update_status(False))
1387
+
1388
+ def refresh_tools(self):
1389
+ """Refresh the tools list."""
1390
+ self._load_registry()
1391
+ self._populate_tools_list()
1392
+ self._populate_test_tool_combo()
1393
+
1394
+ tool_count = len(self.registry) if self.registry else 0
1395
+ self.tools_count_label.config(text=f"{tool_count} available")
1396
+
1397
+ self._add_log("INFO", f"Tools refreshed: {tool_count} tools available")
1398
+
1399
+
1400
+ class MCPManager:
1401
+ """Main class for MCP Manager integration with Pomera."""
1402
+
1403
+ def __init__(self):
1404
+ self.widget = None
1405
+
1406
+ def create_widget(self, parent, app):
1407
+ """Create and return the MCP Manager widget."""
1408
+ self.widget = MCPManagerWidget(parent, app)
1409
+ return self.widget
1410
+
1411
+ def get_default_settings(self):
1412
+ """Return default settings for MCP Manager."""
1413
+ return {
1414
+ "auto_start": False,
1415
+ "log_level": "INFO"
1416
+ }
1417
+