Files
nap-cli/resources/scripts/napseer_mcp_supervisor.py
T

518 lines
19 KiB
Python

#!/usr/bin/env python3
"""Stable, multiplexed stdio supervisor for the Napseer MCP worker.
Codex keeps the stdio process it launches for the lifetime of a client
session. This supervisor owns those client-facing pipes while treating
``napseer_mcp_server.py`` as a replaceable worker:
- valid requests are forwarded without head-of-line blocking and responses
are correlated by JSON-RPC request id;
- notifications never create client-facing responses;
- cancellation stops waiting for a result but retains unfinished work until
the worker acknowledges cleanup, with bounded grace before termination;
- a worker exit fails every affected in-flight request once and never replays
it automatically;
- a replaced worker, or an explicitly configured runtime dependency behind a
stable launcher, is reloaded at the next quiescent request boundary.
The supervisor never logs MCP payloads, tool results, auth state, or child
stderr. Protocol stdout contains JSON-RPC messages only.
"""
from __future__ import annotations
import concurrent.futures
import json
import os
import pathlib
import queue
import select
import signal
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import BinaryIO, Callable
WORKER_PATH = pathlib.Path(
os.environ.get(
"NAPSEER_MCP_WORKER_PATH",
pathlib.Path(__file__).resolve().with_name("napseer_mcp_server.py"),
)
).expanduser()
WORKER_WATCH_PATHS = tuple(
pathlib.Path(value).expanduser()
for value in os.environ.get("NAPSEER_MCP_WORKER_WATCH_PATHS", "").split(os.pathsep)
if value.strip()
)
RESPONSE_TIMEOUT_SECONDS = max(
1,
int(os.environ.get("NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS", "60")),
)
MAX_IN_FLIGHT_REQUESTS = max(
2,
int(os.environ.get("NAPSEER_MCP_MAX_IN_FLIGHT_REQUESTS", "32")),
)
WORKER_RESTARTED_ERROR = -32098
REQUEST_REJECTED_ERROR = -32097
CANCEL_GRACE_SECONDS = max(1, int(os.environ.get("NAPSEER_MCP_CANCEL_GRACE_SECONDS", "10")))
WRITE_TIMEOUT_SECONDS = max(1, int(os.environ.get("NAPSEER_MCP_WRITE_TIMEOUT_SECONDS", "5")))
REQUEST_FINISHED = "notifications/napseer/requestFinished"
_CANCELLED = object()
class WorkerUnavailable(RuntimeError):
"""The replaceable MCP worker could not complete a request."""
class RequestRejected(WorkerUnavailable):
"""The request was not sent to the worker and cannot have executed."""
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 runtime_identity(
worker_path: pathlib.Path,
watch_paths: tuple[pathlib.Path, ...] = (),
) -> tuple[tuple[int, int, int], ...]:
"""Identify every source whose replacement changes the worker runtime."""
return tuple(source_identity(path) for path in (worker_path, *watch_paths))
def parse_message(message: bytes):
try:
payload = json.loads(message)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def request_id(message: bytes):
payload = parse_message(message)
return payload.get("id") if payload is not None else None
def request_key(value) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def request_method(message: bytes) -> str:
payload = parse_message(message)
return str(payload.get("method") or "") if payload is not None else ""
def is_notification(message: bytes) -> bool:
payload = parse_message(message)
return payload is not None and "id" not in payload
def cancelled_request_id(message: bytes):
payload = parse_message(message)
if payload is None or payload.get("method") != "notifications/cancelled":
return False, None
params = payload.get("params") or {}
if not isinstance(params, dict) or "requestId" not in params:
return False, None
return True, params.get("requestId")
def worker_error(message: bytes, *, rejected=False) -> bytes:
payload = {
"jsonrpc": "2.0",
"id": request_id(message),
"error": {
"code": REQUEST_REJECTED_ERROR if rejected else WORKER_RESTARTED_ERROR,
"message": (
"Napseer MCP could not complete this request. The outcome may be "
"uncertain; inspect state before retrying a mutation."
) if not rejected else "Napseer MCP is busy or this request id is still active. This request was not started; retry later.",
},
}
return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
@dataclass
class PendingRequest:
message: bytes
key: str
generation: int
response: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=1))
cancelled: bool = False
cancel_timer: threading.Timer | None = None
class Worker:
def __init__(
self,
path: pathlib.Path,
unsolicited: Callable[[bytes], None] | None = None,
watch_paths: tuple[pathlib.Path, ...] = (),
):
self.path = path
self.watch_paths = tuple(watch_paths)
self.process: subprocess.Popen[bytes] | None = None
self.identity: tuple[tuple[int, int, int], ...] | None = None
self.generation = 0
self.unsolicited = unsolicited
self._lifecycle_lock = threading.RLock()
self._stdin_lock = threading.Lock()
self._pending_lock = threading.Lock()
self._pending: dict[str, PendingRequest] = {}
self._reload_pending = False
def _discard_stderr(self, stream: BinaryIO) -> None:
try:
while stream.read(4096):
pass
finally:
stream.close()
def _pending_count(self, generation: int | None = None) -> int:
with self._pending_lock:
if generation is None:
return len(self._pending)
return sum(1 for item in self._pending.values() if item.generation == generation)
def _signal(self, pending: PendingRequest, value) -> None:
try:
pending.response.put_nowait(value)
except queue.Full:
pass
def _fail_generation(self, generation: int, error: WorkerUnavailable) -> None:
failed = []
with self._pending_lock:
for key, pending in list(self._pending.items()):
if pending.generation != generation:
continue
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
failed.append(pending)
for pending in failed:
self._signal(pending, error)
def _deliver_response(self, generation: int, response: bytes) -> bool:
payload = parse_message(response)
if payload is None:
return False
normalized = response if response.endswith(b"\n") else response + b"\n"
if payload.get("method") == REQUEST_FINISHED:
params = payload.get("params") or {}
if isinstance(params, dict) and "requestId" in params:
key = request_key(params["requestId"])
with self._pending_lock:
pending = self._pending.get(key)
if pending is not None and pending.generation == generation and pending.cancelled:
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
return True
if "id" in payload:
key = request_key(payload.get("id"))
with self._pending_lock:
pending = self._pending.get(key)
if pending is not None and pending.generation == generation:
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
else:
pending = None
if pending is not None:
if not pending.cancelled:
self._signal(pending, normalized)
return True
if generation == self.generation and "method" in payload and self.unsolicited is not None:
self.unsolicited(normalized)
return True
def _read_stdout(self, process: subprocess.Popen[bytes], generation: int) -> None:
stream = process.stdout
if stream is None:
self._fail_generation(generation, WorkerUnavailable("Napseer MCP worker output is unavailable"))
return
invalid_response = False
try:
for response in stream:
if not self._deliver_response(generation, response):
invalid_response = True
break
except (OSError, ValueError):
pass
if invalid_response and process.poll() is None:
try:
process.terminate()
except OSError:
pass
self._fail_generation(
generation,
WorkerUnavailable(
"Napseer MCP worker returned invalid JSON"
if invalid_response
else "Napseer MCP worker exited before responding"
),
)
def _stop_locked(self) -> None:
process = self.process
generation = self.generation
self.process = None
self.identity = None
self._reload_pending = False
if process is None:
return
if process.poll() is None:
try:
process.terminate()
except ProcessLookupError:
pass
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
for stream in (process.stdin, process.stdout):
if stream is not None:
try:
stream.close()
except OSError:
pass
self._fail_generation(generation, WorkerUnavailable("Napseer MCP worker stopped"))
def start(self) -> None:
with self._lifecycle_lock:
self._stop_locked()
try:
identity = runtime_identity(self.path, self.watch_paths)
process = subprocess.Popen(
[sys.executable, "-u", str(self.path)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
env={**os.environ, "NAPSEER_MCP_SUPERVISED": "1"},
)
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.generation += 1
generation = self.generation
self.process = process
os.set_blocking(process.stdin.fileno(), False)
self.identity = identity
threading.Thread(
target=self._discard_stderr,
args=(process.stderr,),
name=f"napseer-mcp-stderr-{generation}",
daemon=True,
).start()
threading.Thread(
target=self._read_stdout,
args=(process, generation),
name=f"napseer-mcp-stdout-{generation}",
daemon=True,
).start()
def stop(self) -> None:
with self._lifecycle_lock:
self._stop_locked()
def ensure_current(self) -> None:
with self._lifecycle_lock:
try:
current_identity = runtime_identity(self.path, self.watch_paths)
except OSError as exc:
self._stop_locked()
raise WorkerUnavailable("Napseer MCP worker is missing") from exc
process_dead = self.process is None or self.process.poll() is not None
source_changed = self.identity is not None and self.identity != current_identity
if source_changed and not process_dead and self._pending_count(self.generation):
self._reload_pending = True
return
if process_dead or self.identity is None or source_changed or self._reload_pending:
self.start()
def _write(self, process: subprocess.Popen[bytes], message: bytes) -> None:
deadline = time.monotonic() + WRITE_TIMEOUT_SECONDS
if not self._stdin_lock.acquire(timeout=WRITE_TIMEOUT_SECONDS):
raise WorkerUnavailable("Napseer MCP worker input timed out")
try:
if self.process is not process or process.poll() is not None or process.stdin is None:
raise WorkerUnavailable("Napseer MCP worker is not running")
try:
remaining = memoryview(message if message.endswith(b"\n") else message + b"\n")
while remaining:
timeout = deadline - time.monotonic()
if timeout <= 0 or self.process is not process:
raise WorkerUnavailable("Napseer MCP worker input timed out")
fd = process.stdin.fileno()
if not select.select([], [fd], [], timeout)[1]:
raise WorkerUnavailable("Napseer MCP worker input timed out")
try:
count = os.write(fd, remaining)
remaining = remaining[count:]
except BlockingIOError:
continue
except (BrokenPipeError, OSError, ValueError) as exc:
raise WorkerUnavailable("Napseer MCP worker input closed") from exc
finally:
self._stdin_lock.release()
def submit(self, message: bytes) -> PendingRequest | None:
self.ensure_current()
with self._lifecycle_lock:
process = self.process
generation = self.generation
if process is None:
raise WorkerUnavailable("Napseer MCP worker is not running")
if is_notification(message):
try:
self._write(process, message)
except WorkerUnavailable:
self.stop()
raise
return None
key = request_key(request_id(message))
pending = PendingRequest(message=message, key=key, generation=generation)
with self._pending_lock:
if key in self._pending:
raise RequestRejected("duplicate in-flight JSON-RPC request id")
if len(self._pending) >= MAX_IN_FLIGHT_REQUESTS:
raise RequestRejected("too many unfinished Napseer MCP requests")
self._pending[key] = pending
try:
self._write(process, message)
except WorkerUnavailable:
with self._pending_lock:
if self._pending.get(key) is pending:
self._pending.pop(key, None)
# A partial write has an uncertain outcome and corrupts framing.
# Stop this generation rather than appending another request to it.
self.stop()
raise
return pending
def cancel(self, value, *, expected: PendingRequest | None = None) -> bool:
key = request_key(value)
with self._pending_lock:
pending = self._pending.get(key)
if expected is not None and pending is not expected:
return False
if pending is None or request_method(pending.message) == "initialize":
return False
if pending.cancelled:
return True
pending.cancelled = True
pending.cancel_timer = threading.Timer(CANCEL_GRACE_SECONDS, self._expire_cancelled, args=(pending,))
pending.cancel_timer.daemon = True
timer = pending.cancel_timer
self._signal(pending, _CANCELLED)
timer.start()
process = self.process
if process is not None and pending.generation == self.generation:
try:
self._write(process, json.dumps({"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": value}}).encode("utf-8"))
except WorkerUnavailable:
self._expire_cancelled(pending)
return True
def _expire_cancelled(self, pending: PendingRequest) -> None:
with self._lifecycle_lock:
with self._pending_lock:
unfinished = self._pending.get(pending.key) is pending
if unfinished and pending.generation == self.generation:
self._stop_locked()
def wait(self, pending: PendingRequest) -> bytes | None:
try:
result = pending.response.get(timeout=RESPONSE_TIMEOUT_SECONDS)
except queue.Empty as exc:
if request_method(pending.message) == "initialize":
# Initialize is not cancellable, but a stuck initialization
# must not permanently consume the worker generation.
self._expire_cancelled(pending)
else:
self.cancel(request_id(pending.message), expected=pending)
raise WorkerUnavailable("Napseer MCP worker response timed out") from exc
if result is _CANCELLED:
return None
if isinstance(result, WorkerUnavailable):
raise result
return result
def supervise() -> int:
output_lock = threading.Lock()
closing = threading.Event()
def emit(response: bytes) -> None:
if closing.is_set():
return
with output_lock:
sys.stdout.buffer.write(response if response.endswith(b"\n") else response + b"\n")
sys.stdout.buffer.flush()
worker = Worker(
WORKER_PATH,
unsolicited=emit,
watch_paths=WORKER_WATCH_PATHS,
)
waiters = concurrent.futures.ThreadPoolExecutor(
max_workers=MAX_IN_FLIGHT_REQUESTS,
thread_name_prefix="napseer-mcp-request",
)
def finish(message: bytes, pending: PendingRequest) -> None:
try:
response = worker.wait(pending)
except WorkerUnavailable:
response = worker_error(message)
if response is not None:
emit(response)
def stop_worker(_signum=None, _frame=None):
closing.set()
worker.stop()
waiters.shutdown(wait=False, cancel_futures=True)
raise SystemExit(0)
signal.signal(signal.SIGTERM, stop_worker)
signal.signal(signal.SIGINT, stop_worker)
try:
for message in sys.stdin.buffer:
if not message.strip():
continue
cancelled, value = cancelled_request_id(message)
if cancelled:
worker.cancel(value)
continue
try:
pending = worker.submit(message)
except WorkerUnavailable as exc:
if not is_notification(message):
emit(worker_error(message, rejected=isinstance(exc, RequestRejected)))
continue
if pending is not None:
waiters.submit(finish, message, pending)
finally:
closing.set()
worker.stop()
waiters.shutdown(wait=True, cancel_futures=True)
return 0
if __name__ == "__main__":
raise SystemExit(supervise())