146 lines
4.2 KiB
Python
146 lines
4.2 KiB
Python
"""terminal-tools FastMCP server — entry module.
|
|
|
|
Run via:
|
|
uv run python -m terminal_tools.server --stdio
|
|
uv run python terminal_tools_server.py --stdio (preferred, see _DEFAULT_LOCAL_SERVERS)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import atexit
|
|
import logging
|
|
import os
|
|
import sys
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def setup_logger() -> None:
|
|
if not logger.handlers:
|
|
stream = sys.stderr if "--stdio" in sys.argv else sys.stdout
|
|
handler = logging.StreamHandler(stream)
|
|
handler.setFormatter(logging.Formatter("[terminal-tools] %(message)s"))
|
|
logger.addHandler(handler)
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
setup_logger()
|
|
|
|
# Suppress FastMCP banner in STDIO mode (mirrors gcu/server.py).
|
|
if "--stdio" in sys.argv:
|
|
import rich.console
|
|
|
|
_orig_console_init = rich.console.Console.__init__
|
|
|
|
def _patched_console_init(self, *args, **kwargs):
|
|
kwargs["file"] = sys.stderr
|
|
_orig_console_init(self, *args, **kwargs)
|
|
|
|
rich.console.Console.__init__ = _patched_console_init
|
|
|
|
|
|
from fastmcp import FastMCP # noqa: E402
|
|
|
|
from terminal_tools import register_terminal_tools # noqa: E402
|
|
from terminal_tools.jobs.manager import get_manager # noqa: E402
|
|
from terminal_tools.pty.tools import get_registry as get_pty_registry # noqa: E402
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _lifespan(_server: FastMCP) -> AsyncIterator[dict]:
|
|
"""Reap children on shutdown so we don't orphan jobs/PTYs.
|
|
|
|
Mirrors the gcu-tools lifespan pattern. Runs in the FastMCP event
|
|
loop on graceful shutdown; the atexit hook below catches abrupt
|
|
exits (SIGTERM, etc.) where lifespan teardown may not complete.
|
|
"""
|
|
parent_pid_env = os.getenv("HIVE_DESKTOP_PARENT_PID")
|
|
if parent_pid_env:
|
|
try:
|
|
parent_pid = int(parent_pid_env)
|
|
asyncio.create_task(_parent_watchdog(parent_pid))
|
|
logger.info("Parent watchdog armed for PID %d", parent_pid)
|
|
except ValueError:
|
|
logger.warning("Invalid HIVE_DESKTOP_PARENT_PID=%r", parent_pid_env)
|
|
|
|
yield {}
|
|
|
|
logger.info("Shutting down — reaping jobs and PTY sessions...")
|
|
try:
|
|
get_manager().shutdown_all(grace_sec=2.0)
|
|
except Exception as e:
|
|
logger.warning("JobManager shutdown error: %s", e)
|
|
try:
|
|
get_pty_registry().shutdown_all()
|
|
except Exception as e:
|
|
logger.warning("PTY registry shutdown error: %s", e)
|
|
|
|
|
|
def _is_alive(pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (ProcessLookupError, PermissionError):
|
|
return False
|
|
|
|
|
|
async def _parent_watchdog(parent_pid: int) -> None:
|
|
"""Self-destruct when the desktop parent dies."""
|
|
while True:
|
|
await asyncio.sleep(2.0)
|
|
if not _is_alive(parent_pid):
|
|
logger.warning("Parent PID %d gone — terminal-tools exiting", parent_pid)
|
|
try:
|
|
get_manager().shutdown_all(grace_sec=1.0)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
get_pty_registry().shutdown_all()
|
|
except Exception:
|
|
pass
|
|
os._exit(0)
|
|
|
|
|
|
def _atexit_reap() -> None:
|
|
"""Last-ditch reaping if lifespan didn't run."""
|
|
try:
|
|
get_manager().shutdown_all(grace_sec=1.0)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
get_pty_registry().shutdown_all()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
atexit.register(_atexit_reap)
|
|
|
|
mcp = FastMCP("terminal-tools", lifespan=_lifespan)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="terminal-tools MCP server")
|
|
parser.add_argument("--port", type=int, default=int(os.getenv("TERMINAL_TOOLS_PORT", "4004")))
|
|
parser.add_argument("--host", default="0.0.0.0")
|
|
parser.add_argument("--stdio", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
tools = register_terminal_tools(mcp)
|
|
|
|
if not args.stdio:
|
|
logger.info("Registered %d terminal-tools: %s", len(tools), tools)
|
|
|
|
if args.stdio:
|
|
mcp.run(transport="stdio")
|
|
else:
|
|
logger.info("Starting terminal-tools on %s:%d", args.host, args.port)
|
|
asyncio.run(mcp.run_async(transport="http", host=args.host, port=args.port))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|