Files
hive/tools/src/terminal_tools/server.py
T
2026-04-30 19:52:34 -07:00

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()