@aliwey/bmo 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,109 @@
1
+ """
2
+ MCP Tool Bridge — calls BMO's MCP tools via SSE protocol.
3
+ Usage:
4
+ python -m tools.mcp_bridge <tool_name> [json_args]
5
+
6
+ Examples:
7
+ python -m tools.mcp_bridge send_telegram_message '{"chat_id": 732356803, "message": "Hello"}'
8
+ python -m tools.mcp_bridge list_background_tasks '{}'
9
+ python -m tools.mcp_bridge check_task_status '{"pid": 12345}'
10
+ python -m tools.mcp_bridge stop_background_task '{"pid": 12345}'
11
+ """
12
+ import asyncio
13
+ import json
14
+ import sys
15
+
16
+ from mcp.client.sse import sse_client
17
+ from mcp.types import JSONRPCMessage
18
+ from mcp.shared.message import SessionMessage
19
+
20
+
21
+ MCP_SERVER_URL = "http://127.0.0.1:4097/sse"
22
+
23
+
24
+ async def call_tool(tool_name: str, arguments: dict) -> dict:
25
+ async with sse_client(url=MCP_SERVER_URL) as (read, write):
26
+ # Initialize
27
+ init = {
28
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
29
+ "params": {
30
+ "protocolVersion": "2024-11-05",
31
+ "capabilities": {},
32
+ "clientInfo": {"name": "mcp-bridge", "version": "1.0.0"},
33
+ },
34
+ }
35
+ msg = JSONRPCMessage.model_validate(init)
36
+ await write.send(SessionMessage(message=msg))
37
+ async for msg in read:
38
+ init_resp = msg.message.model_dump()
39
+ break
40
+
41
+ # Initialized notification
42
+ notif = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
43
+ msg = JSONRPCMessage.model_validate(notif)
44
+ await write.send(SessionMessage(message=msg))
45
+
46
+ # Call tool
47
+ call = {
48
+ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
49
+ "params": {"name": tool_name, "arguments": arguments},
50
+ }
51
+ msg = JSONRPCMessage.model_validate(call)
52
+ await write.send(SessionMessage(message=msg))
53
+
54
+ async for msg in read:
55
+ result = msg.message.model_dump()
56
+ return result
57
+
58
+
59
+ async def list_tools() -> list:
60
+ async with sse_client(url=MCP_SERVER_URL) as (read, write):
61
+ init = {
62
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
63
+ "params": {
64
+ "protocolVersion": "2024-11-05",
65
+ "capabilities": {},
66
+ "clientInfo": {"name": "mcp-bridge", "version": "1.0.0"},
67
+ },
68
+ }
69
+ msg = JSONRPCMessage.model_validate(init)
70
+ await write.send(SessionMessage(message=msg))
71
+ async for msg in read:
72
+ break
73
+
74
+ notif = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
75
+ msg = JSONRPCMessage.model_validate(notif)
76
+ await write.send(SessionMessage(message=msg))
77
+
78
+ req = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
79
+ msg = JSONRPCMessage.model_validate(req)
80
+ await write.send(SessionMessage(message=msg))
81
+
82
+ async for msg in read:
83
+ result = msg.message.model_dump()
84
+ if "result" in result:
85
+ return result["result"].get("tools", [])
86
+
87
+
88
+ async def main():
89
+ if len(sys.argv) < 2:
90
+ print("Usage: python -m tools.mcp_bridge <tool_name> [json_args]")
91
+ print(" python -m tools.mcp_bridge --list-tools")
92
+ sys.exit(1)
93
+
94
+ if sys.argv[1] == "--list-tools":
95
+ tools = await list_tools()
96
+ print(f"Available tools ({len(tools)}):")
97
+ for t in tools:
98
+ print(f" {t['name']}")
99
+ return
100
+
101
+ tool_name = sys.argv[1]
102
+ arguments = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
103
+
104
+ result = await call_tool(tool_name, arguments)
105
+ print(json.dumps(result, indent=2, ensure_ascii=False))
106
+
107
+
108
+ if __name__ == "__main__":
109
+ asyncio.run(main())
@@ -0,0 +1,531 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import time
7
+ from datetime import datetime
8
+ from mcp.server.fastmcp import FastMCP
9
+ from storage.sqlite_storage import SQLiteStorage
10
+ from core.shared_state import pending_permissions
11
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot
12
+ from dotenv import load_dotenv
13
+ from tools.task_registry import (
14
+ register_task, update_task, remove_task, get_task,
15
+ get_active_tasks, check_port_conflict, format_task_list,
16
+ PROJECT_ROOT as REGISTRY_PROJECT_ROOT,
17
+ )
18
+
19
+ load_dotenv()
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Initialize FastMCP
24
+ mcp = FastMCP("BMO-Tools")
25
+ storage = SQLiteStorage()
26
+ BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("TELEGRAM_TOKEN")
27
+ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ LOGS_DIR = os.path.join(PROJECT_ROOT, "logs")
29
+
30
+ os.makedirs(LOGS_DIR, exist_ok=True)
31
+
32
+
33
+ def _start_detached(command: str, log_file: str, cwd: str = None) -> subprocess.Popen:
34
+ """Start a process fully detached with output redirected to a log file."""
35
+ CREATE_NO_WINDOW = 0x08000000
36
+ DETACHED_PROCESS = 0x00000008
37
+
38
+ os.makedirs(os.path.dirname(log_file), exist_ok=True)
39
+
40
+ log_fh = open(log_file, "w", encoding="utf-8")
41
+
42
+ proc = subprocess.Popen(
43
+ command,
44
+ shell=True,
45
+ cwd=cwd or PROJECT_ROOT,
46
+ creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS,
47
+ stdout=log_fh,
48
+ stderr=log_fh,
49
+ )
50
+ return proc
51
+
52
+
53
+ def _poll_log_for_url(log_file: str, timeout: int = 30) -> str | None:
54
+ """Poll a log file for a cloudflared URL. Returns URL or None."""
55
+ start = time.time()
56
+ while time.time() - start < timeout:
57
+ time.sleep(2)
58
+ if os.path.exists(log_file):
59
+ try:
60
+ with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
61
+ content = f.read()
62
+ match = re.search(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com", content)
63
+ if match:
64
+ return match.group(0)
65
+ except IOError:
66
+ pass
67
+ return None
68
+
69
+
70
+ async def _poll_log_for_url_async(log_file: str, timeout: int = 75) -> str | None:
71
+ """Async version — uses asyncio.sleep instead of time.sleep. Returns URL or None."""
72
+ start = time.time()
73
+ while time.time() - start < timeout:
74
+ await asyncio.sleep(2)
75
+ if os.path.exists(log_file):
76
+ try:
77
+ with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
78
+ content = f.read()
79
+ match = re.search(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com", content)
80
+ if match:
81
+ return match.group(0)
82
+ except IOError:
83
+ pass
84
+ return None
85
+
86
+
87
+ @mcp.tool()
88
+ async def request_permission(chat_id: int, reason: str, scope: str) -> str:
89
+ """
90
+ Requests permission from the user for a sensitive action.
91
+ BMO should call this before reading/writing files outside the project or performing destructive actions.
92
+ """
93
+ # 1. Check permanent permissions
94
+ existing = storage.check_permission(chat_id, scope)
95
+ if existing == 1:
96
+ logger.info(f"Permission auto-granted for {chat_id} on {scope}")
97
+ return "granted"
98
+ if existing == 0:
99
+ logger.info(f"Permission auto-denied for {chat_id} on {scope}")
100
+ return "denied"
101
+
102
+ # 2. Need human intervention
103
+ if not BOT_TOKEN:
104
+ return "denied (bot token missing in server env)"
105
+
106
+ bot = Bot(token=BOT_TOKEN)
107
+ event = asyncio.Event()
108
+ key = (chat_id, scope)
109
+ pending_permissions[key] = {"event": event, "result": None}
110
+
111
+ # Use | as separator to avoid issues with paths containing :
112
+ kb = [
113
+ [
114
+ InlineKeyboardButton("✅ Yes", callback_data=f"perm_yes|{scope}"),
115
+ InlineKeyboardButton("❌ No", callback_data=f"perm_no|{scope}")
116
+ ],
117
+ [InlineKeyboardButton("🔒 Yes, Always", callback_data=f"perm_always|{scope}")]
118
+ ]
119
+
120
+ try:
121
+ await bot.send_message(
122
+ chat_id=chat_id,
123
+ text=f"⚠️ <b>BMO SECURITY REQUEST</b>\n\n"
124
+ f"📋 <b>Reason:</b> {reason}\n"
125
+ f"📁 <b>Scope:</b> <code>{scope}</code>\n\n"
126
+ "Do you grant permission for this action?",
127
+ reply_markup=InlineKeyboardMarkup(kb),
128
+ parse_mode="HTML"
129
+ )
130
+
131
+ # 3. Wait for user input
132
+ logger.info(f"Waiting for permission from {chat_id} for {scope}...")
133
+ await asyncio.wait_for(event.wait(), timeout=300) # 5 min timeout
134
+ result = pending_permissions[key]["result"]
135
+ return result
136
+ except asyncio.TimeoutError:
137
+ return "denied (timeout)"
138
+ except Exception as e:
139
+ logger.error(f"Error in request_permission: {e}")
140
+ return f"denied (error: {str(e)})"
141
+ finally:
142
+ pending_permissions.pop(key, None)
143
+
144
+
145
+ @mcp.tool()
146
+ async def get_session_summaries(chat_id: int, count: int = 3) -> list:
147
+ """
148
+ Fetches the last N session summaries for context.
149
+ Use this to understand what happened in previous conversations or to link topics.
150
+ """
151
+ try:
152
+ sessions = storage.list_sessions(chat_id)
153
+ summaries = []
154
+ for s in sessions:
155
+ if s.get("summary"):
156
+ summaries.append({
157
+ "title": s["title"],
158
+ "summary": s["summary"],
159
+ "date": s["updated_at"]
160
+ })
161
+ return summaries[:count]
162
+ except Exception as e:
163
+ logger.error(f"Error in get_session_summaries: {e}")
164
+ return f"Error: {str(e)}"
165
+
166
+
167
+ @mcp.tool()
168
+ async def send_telegram_message(chat_id: int, message: str, parse_mode: str = "HTML") -> str:
169
+ """
170
+ Sends a message to a specific Telegram chat.
171
+
172
+ Args:
173
+ chat_id: The target user's Telegram chat ID
174
+ message: The message text to send
175
+ parse_mode: HTML or Markdown (default: HTML)
176
+ """
177
+ if not BOT_TOKEN:
178
+ return "Error: Bot token not configured"
179
+
180
+ try:
181
+ bot = Bot(token=BOT_TOKEN)
182
+ await bot.send_message(
183
+ chat_id=chat_id,
184
+ text=message,
185
+ parse_mode=parse_mode
186
+ )
187
+ return f"✅ Message sent to {chat_id}"
188
+ except Exception as e:
189
+ logger.error(f"Error sending message: {e}")
190
+
191
+
192
+ @mcp.tool()
193
+ async def start_web_task(
194
+ command: str,
195
+ port: int = 0,
196
+ task_type: str = "server",
197
+ description: str = "",
198
+ log_filename: str = ""
199
+ ) -> str:
200
+ """
201
+ Starts a web-related task (server, tunnel, static file server) fully detached.
202
+ Returns immediately with PID. Use check_task_status() to poll for URL.
203
+
204
+ Args:
205
+ command: The command to run (e.g., "node server.js", "python -m http.server 8080")
206
+ port: The port the service will listen on (for tracking and conflict detection)
207
+ task_type: Type of task — "server", "tunnel", "webchat", "static"
208
+ description: Human-readable description of what this task does
209
+ log_filename: Custom log filename (default: auto-generated based on port/type)
210
+ """
211
+ try:
212
+ # Check for port conflicts
213
+ if port:
214
+ conflict = check_port_conflict(port)
215
+ if conflict:
216
+ return (
217
+ f"⚠️ Port {port} is already in use by:\n"
218
+ f" Type: {conflict['type']}\n"
219
+ f" PID: {conflict['pid']}\n"
220
+ f" Command: {conflict['command']}\n"
221
+ f" Description: {conflict.get('description', 'N/A')}\n\n"
222
+ f"Stop the existing task first with stop_background_task({conflict['pid']})."
223
+ )
224
+
225
+ # Generate log filename if not provided
226
+ if not log_filename:
227
+ if port:
228
+ log_filename = f"task_{task_type}_{port}.log"
229
+ else:
230
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
231
+ log_filename = f"task_{task_type}_{ts}.log"
232
+
233
+ log_file = os.path.join(LOGS_DIR, log_filename)
234
+
235
+ # Start the process detached
236
+ proc = _start_detached(command, log_file)
237
+
238
+ # Register in the task registry
239
+ register_task(
240
+ pid=proc.pid,
241
+ command=command,
242
+ port=port if port else None,
243
+ task_type=task_type,
244
+ description=description,
245
+ log_file=log_file,
246
+ )
247
+
248
+ return (
249
+ f"🚀 Task started successfully!\n"
250
+ f"PID: {proc.pid}\n"
251
+ f"Type: {task_type}\n"
252
+ f"Port: {port if port else 'N/A'}\n"
253
+ f"Log: {log_filename}\n"
254
+ f"Description: {description or 'N/A'}\n\n"
255
+ f"Use check_task_status(pid={proc.pid}) to poll for URL (wait 10-15s first).\n"
256
+ f"Use list_background_tasks() to see all active tasks."
257
+ )
258
+ except Exception as e:
259
+ logger.error(f"Error in start_web_task: {e}")
260
+ return f"Error: {str(e)}"
261
+
262
+
263
+ @mcp.tool()
264
+ async def list_background_tasks() -> str:
265
+ """
266
+ Lists all active background tasks with their status, ports, and URLs.
267
+ Use this to see what's running, find PIDs to stop, or check for port conflicts.
268
+ """
269
+ try:
270
+ return format_task_list()
271
+ except Exception as e:
272
+ logger.error(f"Error in list_background_tasks: {e}")
273
+ return f"Error: {str(e)}"
274
+
275
+
276
+ @mcp.tool()
277
+ async def check_task_status(pid: int, timeout: int = 30) -> str:
278
+ """
279
+ Checks the status of a background task by polling its log file.
280
+ For cloudflared tunnels, this extracts the public URL.
281
+
282
+ Args:
283
+ pid: The Process ID of the task to check
284
+ timeout: Maximum seconds to poll for URL (default: 30)
285
+ """
286
+ try:
287
+ task = get_task(pid)
288
+ if not task:
289
+ return f"❌ Task with PID {pid} not found in registry."
290
+
291
+ if task.get("status") != "running":
292
+ return (
293
+ f"🔴 Task {pid} is not running.\n"
294
+ f"Status: {task.get('status')}\n"
295
+ f"Type: {task.get('type')}\n"
296
+ f"Command: {task.get('command')}\n"
297
+ f"Stopped at: {task.get('stopped_at', 'Unknown')}"
298
+ )
299
+
300
+ # Check if process is still alive
301
+ import psutil
302
+ try:
303
+ proc = psutil.Process(pid)
304
+ if not proc.is_running() or proc.status() == psutil.STATUS_ZOMBIE:
305
+ update_task(pid, status="stopped", stop_reason="process_terminated",
306
+ stopped_at=datetime.now().isoformat())
307
+ return f"🔴 Task {pid} has terminated unexpectedly."
308
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
309
+ update_task(pid, status="stopped", stop_reason="process_not_found",
310
+ stopped_at=datetime.now().isoformat())
311
+ return f"🔴 Task {pid} not found in system processes."
312
+
313
+ # Poll log file for URL
314
+ log_file = task.get("log_file", "")
315
+ url = task.get("url", "")
316
+
317
+ if not url and log_file:
318
+ url = _poll_log_for_url(log_file, timeout=timeout)
319
+ if url:
320
+ update_task(pid, url=url)
321
+
322
+ # Build status response
323
+ lines = [f"🟢 Task {pid} is running:"]
324
+ lines.append(f" Type: {task.get('type')}")
325
+ lines.append(f" Port: {task.get('port', 'N/A')}")
326
+ lines.append(f" Command: {task.get('command')}")
327
+ if task.get("description"):
328
+ lines.append(f" Description: {task['description']}")
329
+ if url:
330
+ lines.append(f" URL: {url}")
331
+ else:
332
+ lines.append(f" URL: Not yet available (log: {os.path.basename(log_file)})")
333
+ lines.append(f" Started: {task.get('start_time', 'Unknown')}")
334
+
335
+ return "\n".join(lines)
336
+ except Exception as e:
337
+ logger.error(f"Error in check_task_status: {e}")
338
+ return f"Error: {str(e)}"
339
+
340
+
341
+ @mcp.tool()
342
+ async def stop_background_task(pid: int) -> str:
343
+ """
344
+ Stops a background task and removes it from the registry.
345
+ Kills the entire process tree to prevent orphaned child processes.
346
+
347
+ Args:
348
+ pid: The Process ID of the task to stop
349
+ """
350
+ try:
351
+ import psutil
352
+
353
+ task = get_task(pid)
354
+ if not task:
355
+ return f"❌ Task with PID {pid} not found in registry."
356
+
357
+ if task.get("status") != "running":
358
+ return f"ℹ️ Task {pid} is already stopped (status: {task.get('status')})."
359
+
360
+ # Kill the process tree
361
+ try:
362
+ parent = psutil.Process(pid)
363
+ children = parent.children(recursive=True)
364
+ for child in children:
365
+ try:
366
+ child.terminate()
367
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
368
+ pass
369
+ parent.terminate()
370
+
371
+ # Wait briefly, then force kill if still alive
372
+ gone, alive = psutil.wait_procs(children + [parent], timeout=3)
373
+ for p in alive:
374
+ try:
375
+ p.kill()
376
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
377
+ pass
378
+ except psutil.NoSuchProcess:
379
+ pass # Process already dead
380
+
381
+ # Update registry
382
+ update_task(pid, status="stopped", stopped_at=datetime.now().isoformat(),
383
+ stop_reason="user_requested")
384
+
385
+ # Clean up log file reference
386
+ log_file = task.get("log_file", "")
387
+
388
+ return (
389
+ f"✅ Task {pid} stopped successfully.\n"
390
+ f"Type: {task.get('type')}\n"
391
+ f"Command: {task.get('command')}\n"
392
+ f"Port: {task.get('port', 'N/A')} freed.\n"
393
+ f"Log preserved at: {log_file or 'N/A'}"
394
+ )
395
+ except Exception as e:
396
+ logger.error(f"Error stopping task {pid}: {e}")
397
+ return f"Error: {str(e)}"
398
+
399
+
400
+ @mcp.tool()
401
+ async def tunnel_webchat(port: int = 3456) -> str:
402
+ """
403
+ Starts a Cloudflare tunnel for a local web service.
404
+ Returns immediately with PID. Use check_task_status() after 15-30s to get the URL.
405
+
406
+ Args:
407
+ port: The local port to tunnel (default: 3456 for webchat)
408
+ """
409
+ try:
410
+ # Check for port conflicts
411
+ conflict = check_port_conflict(port)
412
+ if conflict:
413
+ return (
414
+ f"⚠️ Port {port} already has an active tunnel:\n"
415
+ f" PID: {conflict['pid']}\n"
416
+ f" URL: {conflict.get('url', 'Not yet available')}\n\n"
417
+ f"Use list_background_tasks() to see all active tunnels."
418
+ )
419
+
420
+ # Check if cloudflared is available
421
+ try:
422
+ subprocess.run(
423
+ ["cloudflared", "--version"],
424
+ capture_output=True, timeout=5, check=True,
425
+ creationflags=subprocess.CREATE_NO_WINDOW
426
+ )
427
+ except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
428
+ return (
429
+ "❌ cloudflared is not installed or not in PATH.\n"
430
+ "Install it: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
431
+ )
432
+
433
+ log_filename = f"tunnel_{port}.log"
434
+ log_file = os.path.join(LOGS_DIR, log_filename)
435
+ command = f"cloudflared tunnel --url http://localhost:{port}"
436
+
437
+ # Start detached
438
+ proc = _start_detached(command, log_file)
439
+
440
+ # Register in registry
441
+ register_task(
442
+ pid=proc.pid,
443
+ command=command,
444
+ port=port,
445
+ task_type="tunnel",
446
+ description=f"Tunnel for localhost:{port}",
447
+ log_file=log_file,
448
+ )
449
+
450
+ return (
451
+ f"🌐 Tunnel starting for port {port}!\n"
452
+ f"PID: {proc.pid}\n"
453
+ f"Log: {log_filename}\n\n"
454
+ f"⏳ Wait 15-30 seconds, then call:\n"
455
+ f" check_task_status(pid={proc.pid})\n\n"
456
+ f"Or use list_background_tasks() to see all active tunnels."
457
+ )
458
+ except Exception as e:
459
+ logger.error(f"Error in tunnel_webchat: {e}")
460
+ return f"Error: {str(e)}"
461
+
462
+
463
+ @mcp.tool()
464
+ async def deploy_local_to_web(directory_path: str, port: int = 8080) -> str:
465
+ """
466
+ Deploys a local directory to the public web using a Cloudflare tunnel.
467
+ Starts an HTTP server + tunnel, registers both in the task registry.
468
+ Returns immediately. Use check_task_status() to get the URL.
469
+
470
+ Args:
471
+ directory_path: Path to the directory to serve
472
+ port: Port for the HTTP server (default: 8080)
473
+ """
474
+ try:
475
+ # Ensure directory_path is absolute
476
+ if not os.path.isabs(directory_path):
477
+ directory_path = os.path.abspath(os.path.join(PROJECT_ROOT, directory_path))
478
+
479
+ if not os.path.isdir(directory_path):
480
+ return f"❌ Directory not found: {directory_path}"
481
+
482
+ # Check for port conflicts
483
+ conflict = check_port_conflict(port)
484
+ if conflict:
485
+ return (
486
+ f"⚠️ Port {port} is already in use by:\n"
487
+ f" PID: {conflict['pid']}\n"
488
+ f" Type: {conflict['type']}\n\n"
489
+ f"Stop it first with stop_background_task({conflict['pid']})."
490
+ )
491
+
492
+ # 1. Start HTTP server
493
+ server_log = os.path.join(LOGS_DIR, f"server_{port}.log")
494
+ server_cmd = f"python -m http.server {port} --directory \"{directory_path}\""
495
+ server_proc = _start_detached(server_cmd, server_log)
496
+
497
+ register_task(
498
+ pid=server_proc.pid,
499
+ command=server_cmd,
500
+ port=port,
501
+ task_type="static",
502
+ description=f"Static server for {directory_path}",
503
+ log_file=server_log,
504
+ )
505
+
506
+ # 2. Start cloudflared tunnel
507
+ tunnel_log = os.path.join(LOGS_DIR, f"tunnel_deploy_{port}.log")
508
+ tunnel_cmd = f"cloudflared tunnel --url http://localhost:{port}"
509
+ tunnel_proc = _start_detached(tunnel_cmd, tunnel_log)
510
+
511
+ register_task(
512
+ pid=tunnel_proc.pid,
513
+ command=tunnel_cmd,
514
+ port=port,
515
+ task_type="tunnel",
516
+ description=f"Tunnel for static server on port {port}",
517
+ log_file=tunnel_log,
518
+ )
519
+
520
+ return (
521
+ f"🚀 Deployment started!\n"
522
+ f"Server PID: {server_proc.pid} (port {port})\n"
523
+ f"Tunnel PID: {tunnel_proc.pid}\n"
524
+ f"Directory: {directory_path}\n\n"
525
+ f"⏳ Wait 15-30 seconds, then call:\n"
526
+ f" check_task_status(pid={tunnel_proc.pid})\n\n"
527
+ f"Use list_background_tasks() to see all active tasks."
528
+ )
529
+ except Exception as e:
530
+ logger.error(f"Error in deploy_local_to_web: {e}")
531
+ return f"Error: {str(e)}"
@@ -0,0 +1,20 @@
1
+ """Register the MCP server as a background task."""
2
+ import os
3
+ import sys
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
5
+
6
+ from tools.task_registry import register_task, get_active_tasks, format_task_list
7
+
8
+ # Register the MCP server (PID 22584, port 4097)
9
+ register_task(
10
+ pid=22584,
11
+ command="python -m tools.run_mcp_standalone",
12
+ port=4097,
13
+ task_type="server",
14
+ description="BMO MCP SSE Server (standalone) for tool access",
15
+ log_file="logs/mcp_standalone.log",
16
+ )
17
+
18
+ print("Registered MCP server in task registry.")
19
+ print("\nActive tasks:")
20
+ print(format_task_list())
@@ -0,0 +1,32 @@
1
+ @echo off
2
+ REM run_detached.bat — Windows detached process wrapper
3
+ REM Usage: run_detached.bat <command> <log_file>
4
+ REM Starts a command detached with stdout+stderr redirected to log file
5
+ REM Example: run_detached.bat "cloudflared tunnel --url http://localhost:3456" "logs\tunnel_3456.log"
6
+
7
+ setlocal enabledelayedexpansion
8
+
9
+ set "CMD=%~1"
10
+ set "LOG=%~2"
11
+
12
+ if "%CMD%"=="" (
13
+ echo ERROR: No command provided.
14
+ echo Usage: run_detached.bat ^<command^> ^<log_file^>
15
+ exit /b 1
16
+ )
17
+
18
+ if "%LOG%"=="" (
19
+ echo ERROR: No log file provided.
20
+ echo Usage: run_detached.bat ^<command^> ^<log_file^>
21
+ exit /b 1
22
+ )
23
+
24
+ REM Create log directory if it doesn't exist
25
+ for %%F in ("%LOG%") do set "LOG_DIR=%%~dpF"
26
+ if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
27
+
28
+ REM Start the command in a hidden window, redirecting both stdout and stderr
29
+ start /B cmd /c "%CMD% > "%LOG%" 2>&1"
30
+
31
+ REM Return the exit code (0 = success starting)
32
+ echo %ERRORLEVEL%