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
+277 -38
View File
@@ -20,6 +20,7 @@ import ast
import contextlib
import json
import hashlib
import http.client
import os
import pathlib
import select
@@ -47,6 +48,10 @@ SCRIPT_NAMES = (
"nap_install.py",
)
HTTP_TIMEOUT_SECONDS = int(os.environ.get("NAPSEER_HTTP_TIMEOUT_SECONDS", "30"))
API_CONNECT_ATTEMPT_TIMEOUT_SECONDS = max(
0.1,
float(os.environ.get("NAPSEER_CONNECT_ATTEMPT_TIMEOUT_SECONDS", "2")),
)
LOCAL_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
RUNTIME_SCRIPT_NAMES = (
@@ -136,13 +141,85 @@ def api_proxy_url(parsed):
return proxies.get(parsed.scheme) or proxies.get("all")
def connect_with_address_fallback(
address,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
source_address=None,
*,
resolver=socket.getaddrinfo,
socket_factory=socket.socket,
):
"""Connect without letting one unreachable DNS address consume the whole timeout."""
host, port = address
addresses = list(resolver(host, port, 0, socket.SOCK_STREAM))
addresses.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1)
if not addresses:
raise OSError("getaddrinfo returned no addresses")
if timeout is socket._GLOBAL_DEFAULT_TIMEOUT or timeout is None:
attempt_timeout = API_CONNECT_ATTEMPT_TIMEOUT_SECONDS
established_timeout = None
else:
established_timeout = float(timeout)
attempt_timeout = min(established_timeout, API_CONNECT_ATTEMPT_TIMEOUT_SECONDS)
last_error = None
for family, socktype, proto, _canonname, sockaddr in addresses:
sock = None
try:
sock = socket_factory(family, socktype, proto)
sock.settimeout(attempt_timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
sock.settimeout(established_timeout)
return sock
except OSError as exc:
last_error = exc
if sock is not None:
sock.close()
if last_error is not None:
raise last_error
raise OSError("connection failed without an address error")
class AddressFallbackHttpConnection(http.client.HTTPConnection):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._create_connection = connect_with_address_fallback
class AddressFallbackHttpsConnection(http.client.HTTPSConnection):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._create_connection = connect_with_address_fallback
class AddressFallbackHttpHandler(urllib.request.HTTPHandler):
def http_open(self, request):
return self.do_open(AddressFallbackHttpConnection, request)
class AddressFallbackHttpsHandler(urllib.request.HTTPSHandler):
def https_open(self, request):
return self.do_open(
AddressFallbackHttpsConnection,
request,
context=self._context,
)
def api_urlopen(request, timeout):
parsed = urllib.parse.urlparse(request.full_url)
proxy = normalize_proxy_url(api_proxy_url(parsed))
if not proxy:
return urllib.request.urlopen(request, timeout=timeout)
proxy_url = urllib.parse.urlunparse(proxy)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({parsed.scheme: proxy_url}))
proxies = {}
if proxy:
proxies[parsed.scheme] = urllib.parse.urlunparse(proxy)
opener = urllib.request.build_opener(
urllib.request.ProxyHandler(proxies),
AddressFallbackHttpHandler(),
AddressFallbackHttpsHandler(),
)
return opener.open(request, timeout=timeout)
@@ -748,6 +825,25 @@ def cli_option(args, *names, default=None):
return default
def validate_cli_options(args, *, flags=(), value_options=(), command_label):
index = 0
while index < len(args):
item = args[index]
if item in flags:
index += 1
continue
if item in value_options:
if index + 1 >= len(args):
raise RuntimeError(f"{item} requires a value")
index += 2
continue
raise RuntimeError(
f"unexpected argument {item!r} for `{command_label}`. "
"Bash expands `!nap`; if this followed `!nap update`, cancel it "
"and run `nap update` without `!`."
)
def start_service(kind, args):
wrapper = runtime_asset_path("napseer_mcp_server.py")
require_runtime_assets()
@@ -1026,29 +1122,70 @@ def mcp_status():
worker = runtime_asset_path("napseer_mcp_server.py")
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
missing = runtime_assets_missing()
probe = mcp_runtime_probe() if not missing else {"status": "not_run", "transport": False, "read": False}
probe = mcp_runtime_probe() if not missing else {
"status": "not_run",
"transport": False,
"contract": False,
"local_state": False,
}
runtime_ready = bool(
not missing
and probe.get("transport")
and probe.get("contract")
and probe.get("local_state")
)
lifecycle = mcp_lifecycle_summary(missing, probe)
if missing:
next_step = "nap mcp install"
elif not probe.get("transport") or not probe.get("contract"):
next_step = "nap mcp update; then restart the MCP client"
elif not runtime_ready:
next_step = "nap doctor"
else:
next_step = None
return {
"status": "probe_ok" if not missing and probe.get("status") == "ok" else "repair_required",
"status": "probe_ok" if runtime_ready else "repair_required",
"supervisor_installed": supervisor.is_file(),
"worker_installed": worker.is_file(),
"runtime_assets_missing": missing,
"fresh_process_probe": probe,
"lifecycle": lifecycle,
"client_connection": "not_observable",
"message": (
"A fresh MCP supervisor transport and authenticated read succeeded. Existing client connections are separate; restart the client if it reports Transport closed."
if probe.get("status") == "ok"
else "The installed MCP runtime did not pass a fresh-process probe."
"A fresh supervisor initialized, listed the contract, and read local status. Remote project access was not tested; existing client connections remain separate."
if runtime_ready
else "The installed MCP runtime did not pass its fresh local-process lifecycle probe."
),
"serve_command": f"{sys.executable} {supervisor}",
"next": None if not missing else "nap mcp install",
"next": next_step,
}
def mcp_runtime_probe(timeout_seconds=12):
def mcp_lifecycle_summary(missing, probe):
authentication = probe.get("authentication") or {}
project = probe.get("project") or {}
return {
"runtime": "missing" if missing else "installed",
"transport": "ready" if probe.get("transport") else "failed",
"contract": "ready" if probe.get("contract") else "failed",
"local_state": "ready" if probe.get("local_state") else "failed",
"authentication": "configured" if authentication.get("configured") else "unconfigured",
"project": "configured" if project.get("configured") else "unconfigured",
"remote_project_access": probe.get("remote_project_access") or "not_tested",
}
def mcp_runtime_probe(timeout_seconds=6):
supervisor = runtime_asset_path("napseer_mcp_supervisor.py")
worker = runtime_asset_path("napseer_mcp_server.py")
if not supervisor.is_file() or not worker.is_file():
return {"status": "not_installed", "transport": False, "read": False}
return {
"status": "not_installed",
"transport": False,
"contract": False,
"local_state": False,
}
deadline = time.monotonic() + max(0.1, float(timeout_seconds))
environment = os.environ.copy()
environment["NAPSEER_PROJECT_ROOT"] = str(pathlib.Path.cwd())
process = subprocess.Popen(
@@ -1060,50 +1197,115 @@ def mcp_runtime_probe(timeout_seconds=12):
env=environment,
)
def send_message(message):
if process.stdin is None:
raise RuntimeError("MCP probe input is unavailable")
process.stdin.write(json.dumps(message) + "\n")
process.stdin.flush()
def request(request_id, method, params=None):
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
message["params"] = params
process.stdin.write(json.dumps(message) + "\n")
process.stdin.flush()
ready, _, _ = select.select([process.stdout], [], [], timeout_seconds)
send_message(message)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("MCP probe total deadline exceeded")
ready, _, _ = select.select([process.stdout], [], [], remaining)
if not ready:
raise TimeoutError("MCP probe timed out")
raise TimeoutError("MCP probe total deadline exceeded")
return json.loads(process.stdout.readline())
def tool_payload(response):
result = response.get("result") or {}
if result.get("isError"):
return None
structured = result.get("structuredContent")
if isinstance(structured, dict):
return structured
for item in result.get("content") or []:
if not isinstance(item, dict) or item.get("type") != "text":
continue
try:
parsed = json.loads(item.get("text") or "")
except (TypeError, ValueError):
continue
if isinstance(parsed, dict):
return parsed
return None
stage = "initialize"
initialized_ok = False
contract_ok = False
try:
initialized = request(1, "initialize", {
"protocolVersion": "2025-03-26",
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "nap-doctor", "version": CLI_RELEASE_VERSION},
})
listed = request(2, "tools/list")
read = request(3, "tools/call", {"name": "nap_library_ls", "arguments": {}})
tools = ((listed.get("result") or {}).get("tools") or [])
read_result = read.get("result") or {}
read_ok = not bool(read_result.get("isError"))
initialized_ok = bool(initialized.get("result"))
stage = "initialized_notification"
send_message({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
stage = "tools_list"
listed = request(2, "tools/list")
tools = ((listed.get("result") or {}).get("tools") or [])
contract_ok = bool(tools)
stage = "local_status"
local_status = request(3, "tools/call", {"name": "nap_whoami", "arguments": {}})
local_payload = tool_payload(local_status)
local_ok = isinstance(local_payload, dict)
return {
"status": "ok" if initialized_ok and tools and read_ok else "failed",
"transport": bool(initialized_ok and tools),
"read": read_ok,
"status": "local_ready" if initialized_ok and contract_ok and local_ok else "failed",
"transport": initialized_ok,
"contract": contract_ok,
"local_state": local_ok,
"read": local_ok,
"read_scope": "local_status",
"tool_count": len(tools),
"authentication": {
"configured": bool((local_payload or {}).get("has_token")),
"verified": False,
},
"project": {
"configured": bool(
(local_payload or {}).get("project_configured")
or (local_payload or {}).get("project_id")
),
"verified": False,
},
"remote_project_access": "not_tested",
}
except Exception as exc:
return {
"status": "failed",
"transport": False,
"read": False,
"transport": initialized_ok,
"contract": contract_ok,
"local_state": False,
"error_class": exc.__class__.__name__,
"failed_stage": stage,
}
finally:
if process.stdin is not None and not process.stdin.closed:
try:
process.stdin.close()
except OSError:
pass
if process.poll() is None:
shutdown_grace = max(0.0, min(0.25, deadline - time.monotonic()))
if shutdown_grace:
try:
process.wait(timeout=shutdown_grace)
except subprocess.TimeoutExpired:
process.terminate()
else:
process.terminate()
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
process.wait(timeout=1)
def handle_mcp(args):
@@ -1140,25 +1342,46 @@ def doctor_status():
auth_error = "auth file is unreadable or invalid JSON"
missing = runtime_assets_missing()
issues = []
setup = []
if auth_error:
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth login"})
issues.append({"code": "auth_file_invalid", "message": auth_error, "next": "nap auth repair"})
elif not auth_path.exists():
issues.append({"code": "project_not_initialized", "message": "current folder is not initialized", "next": "nap init"})
setup.append({
"code": "project_not_initialized",
"message": "current folder is not initialized; anonymous setup is available without login",
"next": "nap init or invoke a project-scoped MCP tool",
})
elif not auth.get("project_id"):
issues.append({"code": "project_not_attached", "message": "no project is attached", "next": "nap project attach"})
setup.append({
"code": "project_not_configured",
"message": "no project is configured for this folder; anonymous setup is available without login",
"next": "nap init",
})
if missing:
issues.append({"code": "runtime_assets_missing", "message": "local MCP runtime is incomplete", "next": "nap mcp install"})
if not runtime_asset_path("napseer_mcp_supervisor.py").is_file():
issues.append({"code": "supervisor_missing", "message": "stable MCP supervisor is not installed", "next": "nap mcp install"})
probe = mcp_runtime_probe() if not missing else {"status": "not_run", "transport": False, "read": False}
if not missing and probe.get("status") != "ok":
probe = mcp_runtime_probe() if not missing else {
"status": "not_run",
"transport": False,
"contract": False,
"local_state": False,
}
probe_ok = bool(probe.get("transport") and probe.get("contract") and probe.get("local_state"))
if not missing and not probe_ok:
issues.append({
"code": "mcp_fresh_probe_failed",
"message": "fresh MCP transport or authenticated read failed",
"next": "nap mcp update; then restart the MCP client",
"code": "mcp_local_probe_failed",
"message": "fresh MCP transport, contract listing, or local status read failed",
"next": (
"nap mcp update; then restart the MCP client"
if not probe.get("transport")
else "inspect the failed_stage field and local project binding"
),
})
status = "repair_required" if issues else ("setup_available" if setup else "ok")
return {
"status": "ok" if not issues else "repair_required",
"status": status,
"lifecycle": mcp_lifecycle_summary(missing, probe),
"checks": {
"state_directory": state["state_dir_exists"],
"auth_file": auth_path.exists() and auth_error is None,
@@ -1172,6 +1395,7 @@ def doctor_status():
"client_connection": "not_observable",
},
"issues": issues,
"setup": setup,
}
@@ -1227,6 +1451,7 @@ def main(argv):
print_json(version_status())
return 0
if command == "update":
validate_cli_options(args, command_label="nap update")
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0
@@ -1241,6 +1466,20 @@ def main(argv):
raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args)
if command == "auth":
if args and args[0] in {"login", "login-local", "operator-login"}:
validate_cli_options(
args[1:],
flags={"--no-browser"},
value_options={
"--frontend-url",
"--api-base-url",
"--base-url",
"--timeout",
"--timeout-seconds",
"--port",
},
command_label="nap auth login",
)
if args and args[0] == "repair":
return run_wrapper("auth", ["repair", *args[1:]])
if args and args[0] == "refresh":