fix: harden MCP and auth runtime lifecycles

This commit is contained in:
HumbleTurtle
2026-08-19 23:35:13 -03:00
parent 829d54b080
commit 7ca30e690d
14 changed files with 2050 additions and 285 deletions
+301 -103
View File
@@ -1,31 +1,36 @@
#!/usr/bin/env python3
"""Stable stdio supervisor for the generated Napseer MCP worker.
"""Stable, multiplexed stdio supervisor for the 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
session. This supervisor owns those client-facing pipes while treating
``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.
- 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 releases the matching pending request and forwards the
notification to the worker;
- a worker exit fails every affected in-flight request once and never replays
it automatically;
- a replaced worker 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 responses only.
stderr. Protocol stdout contains JSON-RPC messages only.
"""
from __future__ import annotations
import concurrent.futures
import json
import os
import pathlib
import selectors
import queue
import signal
import subprocess
import sys
import threading
from typing import BinaryIO
from dataclasses import dataclass, field
from typing import BinaryIO, Callable
WORKER_PATH = pathlib.Path(
@@ -36,9 +41,14 @@ WORKER_PATH = pathlib.Path(
).expanduser()
RESPONSE_TIMEOUT_SECONDS = max(
1,
int(os.environ.get("NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS", "300")),
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
_CANCELLED = object()
class WorkerUnavailable(RuntimeError):
@@ -50,22 +60,41 @@ def source_identity(path: pathlib.Path) -> tuple[int, int, int]:
return stat.st_ino, stat.st_size, stat.st_mtime_ns
def request_id(message: bytes):
def parse_message(message: bytes):
try:
payload = json.loads(message)
except (TypeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return payload.get("id")
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:
try:
payload = json.loads(message)
except (TypeError, ValueError):
return False
return isinstance(payload, dict) and "id" not in payload
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) -> bytes:
@@ -74,17 +103,35 @@ def worker_error(message: bytes) -> bytes:
"id": request_id(message),
"error": {
"code": WORKER_RESTARTED_ERROR,
"message": "Napseer MCP worker restarted; retry this tool call.",
"message": (
"Napseer MCP could not complete this request. The outcome may be "
"uncertain; inspect state before retrying a mutation."
),
},
}
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))
class Worker:
def __init__(self, path: pathlib.Path):
def __init__(self, path: pathlib.Path, unsolicited: Callable[[bytes], None] | None = None):
self.path = path
self.process: subprocess.Popen[bytes] | None = None
self.identity: 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:
@@ -93,35 +140,82 @@ class Worker:
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 _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 stop(self) -> None:
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)
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 "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)
else:
pending = None
if pending is not None:
self._signal(pending, normalized)
return True
if "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:
@@ -133,83 +227,187 @@ class Worker:
process.wait()
for stream in (process.stdin, process.stdout):
if stream is not None:
stream.close()
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 = 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.generation += 1
generation = self.generation
self.process = process
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:
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()
with self._lifecycle_lock:
try:
current_identity = source_identity(self.path)
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 _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 _write(self, process: subprocess.Popen[bytes], message: bytes) -> None:
with self._stdin_lock:
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:
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
def exchange(self, message: bytes) -> bytes | None:
def submit(self, message: bytes) -> PendingRequest | None:
self.ensure_current()
process = self.process
if process is None or process.stdin is None:
with self._lifecycle_lock:
process = self.process
generation = self.generation
if process 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):
self._write(process, message)
return None
return self._read_response()
key = request_key(request_id(message))
pending = PendingRequest(message=message, key=key, generation=generation)
with self._pending_lock:
if key in self._pending:
raise WorkerUnavailable("duplicate in-flight JSON-RPC request id")
if len(self._pending) >= MAX_IN_FLIGHT_REQUESTS:
raise WorkerUnavailable("too many in-flight 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)
raise
return pending
def cancel(self, value) -> bool:
key = request_key(value)
with self._pending_lock:
pending = self._pending.get(key)
if pending is None or request_method(pending.message) == "initialize":
return False
self._pending.pop(key, None)
self._signal(pending, _CANCELLED)
return True
def wait(self, pending: PendingRequest) -> bytes | None:
try:
result = pending.response.get(timeout=RESPONSE_TIMEOUT_SECONDS)
except queue.Empty as exc:
with self._pending_lock:
if self._pending.get(pending.key) is pending:
self._pending.pop(pending.key, None)
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:
worker = Worker(WORKER_PATH)
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)
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)
for message in sys.stdin.buffer:
if not message.strip():
continue
try:
response = worker.exchange(message)
except WorkerUnavailable:
worker.stop()
try:
for message in sys.stdin.buffer:
if not message.strip():
continue
cancelled, value = cancelled_request_id(message)
if cancelled:
worker.cancel(value)
try:
worker.submit(message)
except WorkerUnavailable:
pass
continue
try:
worker.start()
pending = worker.submit(message)
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()
if not is_notification(message):
emit(worker_error(message))
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