218 lines
6.8 KiB
Python
218 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Stable stdio supervisor for the generated Napseer MCP worker.
|
|
|
|
Codex keeps the stdio process it launches for the lifetime of a client
|
|
session. If that process exits, Codex keeps a closed transport and does not
|
|
respawn it. This supervisor owns the client-facing pipes and treats
|
|
``napseer_mcp_server.py`` as a replaceable worker:
|
|
|
|
- a worker that exited between requests is restarted before the next request;
|
|
- a wrapper file replaced by ``nap update`` is reloaded before the next request;
|
|
- a worker that exits during a tool call is restarted, while the interrupted
|
|
call receives a bounded JSON-RPC error and is never replayed automatically.
|
|
|
|
The supervisor never logs MCP payloads, tool results, auth state, or child
|
|
stderr. Protocol stdout contains JSON-RPC responses only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import selectors
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from typing import BinaryIO
|
|
|
|
|
|
WORKER_PATH = pathlib.Path(
|
|
os.environ.get(
|
|
"NAPSEER_MCP_WORKER_PATH",
|
|
pathlib.Path(__file__).resolve().with_name("napseer_mcp_server.py"),
|
|
)
|
|
).expanduser()
|
|
RESPONSE_TIMEOUT_SECONDS = max(
|
|
1,
|
|
int(os.environ.get("NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS", "300")),
|
|
)
|
|
WORKER_RESTARTED_ERROR = -32098
|
|
|
|
|
|
class WorkerUnavailable(RuntimeError):
|
|
"""The replaceable MCP worker could not complete a request."""
|
|
|
|
|
|
def source_identity(path: pathlib.Path) -> tuple[int, int, int]:
|
|
stat = path.stat()
|
|
return stat.st_ino, stat.st_size, stat.st_mtime_ns
|
|
|
|
|
|
def request_id(message: bytes):
|
|
try:
|
|
payload = json.loads(message)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
return payload.get("id")
|
|
|
|
|
|
def is_notification(message: bytes) -> bool:
|
|
try:
|
|
payload = json.loads(message)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return isinstance(payload, dict) and "id" not in payload
|
|
|
|
|
|
def worker_error(message: bytes) -> bytes:
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id(message),
|
|
"error": {
|
|
"code": WORKER_RESTARTED_ERROR,
|
|
"message": "Napseer MCP worker restarted; retry this tool call.",
|
|
},
|
|
}
|
|
return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
|
|
|
|
class Worker:
|
|
def __init__(self, path: pathlib.Path):
|
|
self.path = path
|
|
self.process: subprocess.Popen[bytes] | None = None
|
|
self.identity: tuple[int, int, int] | None = None
|
|
|
|
def _discard_stderr(self, stream: BinaryIO) -> None:
|
|
try:
|
|
for _line in stream:
|
|
pass
|
|
finally:
|
|
stream.close()
|
|
|
|
def start(self) -> None:
|
|
self.stop()
|
|
try:
|
|
identity = source_identity(self.path)
|
|
process = subprocess.Popen(
|
|
[sys.executable, "-u", str(self.path)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
bufsize=0,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise WorkerUnavailable("unable to start Napseer MCP worker") from exc
|
|
if process.stdin is None or process.stdout is None or process.stderr is None:
|
|
process.kill()
|
|
process.wait()
|
|
raise WorkerUnavailable("Napseer MCP worker pipes are unavailable")
|
|
self.process = process
|
|
self.identity = identity
|
|
threading.Thread(
|
|
target=self._discard_stderr,
|
|
args=(process.stderr,),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def stop(self) -> None:
|
|
process = self.process
|
|
self.process = None
|
|
self.identity = None
|
|
if process is None:
|
|
return
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
for stream in (process.stdin, process.stdout):
|
|
if stream is not None:
|
|
stream.close()
|
|
|
|
def ensure_current(self) -> None:
|
|
try:
|
|
current_identity = source_identity(self.path)
|
|
except OSError as exc:
|
|
self.stop()
|
|
raise WorkerUnavailable("Napseer MCP worker is missing") from exc
|
|
if (
|
|
self.process is None
|
|
or self.process.poll() is not None
|
|
or self.identity != current_identity
|
|
):
|
|
self.start()
|
|
|
|
def _read_response(self) -> bytes:
|
|
process = self.process
|
|
if process is None or process.stdout is None:
|
|
raise WorkerUnavailable("Napseer MCP worker is not running")
|
|
selector = selectors.DefaultSelector()
|
|
try:
|
|
selector.register(process.stdout, selectors.EVENT_READ)
|
|
if not selector.select(RESPONSE_TIMEOUT_SECONDS):
|
|
raise WorkerUnavailable("Napseer MCP worker response timed out")
|
|
response = process.stdout.readline()
|
|
finally:
|
|
selector.close()
|
|
if not response:
|
|
raise WorkerUnavailable("Napseer MCP worker exited before responding")
|
|
try:
|
|
json.loads(response)
|
|
except ValueError as exc:
|
|
raise WorkerUnavailable("Napseer MCP worker returned invalid JSON") from exc
|
|
return response if response.endswith(b"\n") else response + b"\n"
|
|
|
|
def exchange(self, message: bytes) -> bytes | None:
|
|
self.ensure_current()
|
|
process = self.process
|
|
if process is None or process.stdin is None:
|
|
raise WorkerUnavailable("Napseer MCP worker is not running")
|
|
try:
|
|
process.stdin.write(message if message.endswith(b"\n") else message + b"\n")
|
|
process.stdin.flush()
|
|
except (BrokenPipeError, OSError) as exc:
|
|
raise WorkerUnavailable("Napseer MCP worker input closed") from exc
|
|
if is_notification(message):
|
|
return None
|
|
return self._read_response()
|
|
|
|
|
|
def supervise() -> int:
|
|
worker = Worker(WORKER_PATH)
|
|
|
|
def stop_worker(_signum=None, _frame=None):
|
|
worker.stop()
|
|
raise SystemExit(0)
|
|
|
|
signal.signal(signal.SIGTERM, stop_worker)
|
|
signal.signal(signal.SIGINT, stop_worker)
|
|
|
|
for message in sys.stdin.buffer:
|
|
if not message.strip():
|
|
continue
|
|
try:
|
|
response = worker.exchange(message)
|
|
except WorkerUnavailable:
|
|
worker.stop()
|
|
try:
|
|
worker.start()
|
|
except WorkerUnavailable:
|
|
pass
|
|
response = None if is_notification(message) else worker_error(message)
|
|
if response is not None:
|
|
sys.stdout.buffer.write(response)
|
|
sys.stdout.buffer.flush()
|
|
|
|
worker.stop()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(supervise())
|