@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.
- package/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- 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%
|