Repair MCP diagnostics and gateway lifecycle

This commit is contained in:
HumbleTurtle
2026-08-01 03:50:28 -03:00
parent ffd31cf568
commit bfda22f59b
16 changed files with 663 additions and 110 deletions
+127 -61
View File
@@ -20,6 +20,7 @@ import json
import hashlib
import os
import pathlib
import select
import shutil
import signal
import socket
@@ -55,8 +56,8 @@ RUNTIME_SCRIPT_NAMES = (
"napseer_mcp_supervisor.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_RELEASE_VERSION = "0.2.0"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-07-29"
CLI_RELEASE_VERSION = "0.2.1"
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01"
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
INSTALL_PATHS = {
@@ -95,13 +96,6 @@ COMMAND_METADATA = {
"update": {"mutates": True, "visible": True},
"version": {"mutates": False, "visible": True},
"help": {"mutates": False, "visible": True},
"chat": {"mutates": True, "visible": False},
"plan": {"mutates": True, "visible": False},
"lineage": {"mutates": False, "visible": False},
"agent": {"mutates": True, "visible": False},
"feedback": {"mutates": True, "visible": False},
"where": {"mutates": False, "visible": False},
"install": {"mutates": True, "visible": False},
}
@@ -748,7 +742,17 @@ def start_service(kind, args):
if kind == "ui" and "--no-browser" not in args:
command.remove("--no-browser")
with log_path.open("ab") as log:
kwargs = {"stdout": log, "stderr": subprocess.STDOUT, "cwd": str(pathlib.Path.cwd())}
environment = os.environ.copy()
gateway_auth_path = preferred_state_dir() / "gateway-auth.json"
if kind == "gateway" and gateway_auth_path.is_file():
environment["NAPSEER_AUTH_FILE"] = str(gateway_auth_path)
environment["NAPSEER_PROJECT_ROOT"] = str(pathlib.Path.cwd())
kwargs = {
"stdout": log,
"stderr": subprocess.STDOUT,
"cwd": str(pathlib.Path.cwd()),
"env": environment,
}
if os.name != "nt":
kwargs["start_new_session"] = True
else:
@@ -850,41 +854,45 @@ def service_logs(kind, args):
def handle_gateway(args):
subcommand = args[0] if args else "ls"
subcommand = args[0] if args else "status"
rest = args[1:] if args else []
if subcommand in {"help", "-h", "--help"}:
print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]")
print("Usage: nap gateway [start|stop|status|logs|setup|repair|terminal|schedule|vault]")
print(" nap gateway start [--port PORT] Start the local gateway service.")
print(" nap gateway status Show the managed gateway process.")
print(" nap gateway configure [--command CMD] Configure the local gateway command.")
print(" nap gateway setup Create or migrate local gateway storage.")
print(" nap gateway setup [--command CMD] Create local storage and set the default command.")
print(" nap gateway repair Create dedicated gateway worker credentials.")
print(" nap gateway vault Show gateway vault status and pending setup requests.")
print(" nap gateway vault process Complete pending setup requests with the vault/content passphrase.")
print(" nap gateway vault rotate-secret --kind memory")
print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.")
return 0
if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}:
print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
print("Usage: nap gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]")
print(" status Show local gateway state and pending setup requests.")
print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.")
print(" rotate-secret Rotate the memory project secret from the gateway worker path.")
print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.")
return 0
if subcommand == "vault" and rest and rest[0] in {"list", "ls"}:
raise RuntimeError("unknown gateway vault command: list; use `nap gateway vault status`")
if subcommand == "process":
return run_wrapper("gateway", ["vault", "process", *rest])
raise RuntimeError("unknown gateway command: process; use `nap gateway vault process`")
if subcommand == "start":
return print_json(start_service("gateway", ["--no-browser", *rest])) or 0
if subcommand == "stop":
return print_json(stop_service("gateway")) or 0
if subcommand in {"ls", "list", "status"}:
if subcommand == "status":
return print_json(list_service("gateway")) or 0
if subcommand in {"logs", "log"}:
if subcommand == "logs":
return print_json(service_logs("gateway", rest)) or 0
if subcommand == "configure":
return run_wrapper("gateway", ["configure", *rest])
if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}:
raise RuntimeError("unknown gateway command: configure; use `nap gateway setup --command CMD`")
if subcommand in {"restart", "kill"}:
raise RuntimeError(f"unknown gateway command: {subcommand}; use start, stop, or terminal operations")
if subcommand == "service":
raise RuntimeError("unknown gateway command: service; use `nap gateway repair` then `nap gateway start`")
if subcommand in {"setup", "repair", "vault", "terminal", "schedule"}:
return run_wrapper("gateway", [subcommand, *rest])
raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service")
raise RuntimeError("unknown gateway command; use nap gateway start|stop|status|logs|setup|repair|terminal|schedule|vault")
def print_json(value):
@@ -938,9 +946,9 @@ def print_command_help(command):
" serve Run the stable stdio supervisor."
),
"gateway": (
"Usage: nap gateway [setup|start|stop|restart|status|logs|vault|terminal|schedule|service]\n"
"Common lifecycle commands are setup, start, stop, restart, status, and logs.\n"
"Vault, terminal, schedule, and service are advanced capability groups."
"Usage: nap gateway [start|stop|status|logs|setup|repair|terminal|schedule|vault]\n"
"Use setup once, repair only when worker credentials are missing or revoked,\n"
"then operate the daemon through terminal, schedule, and vault."
),
"update": (
"Usage: nap update\n"
@@ -972,16 +980,86 @@ 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}
return {
"status": "ok" if not missing else "repair_required",
"status": "probe_ok" if not missing and probe.get("status") == "ok" else "repair_required",
"supervisor_installed": supervisor.is_file(),
"worker_installed": worker.is_file(),
"runtime_assets_missing": missing,
"fresh_process_probe": probe,
"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."
),
"serve_command": f"{sys.executable} {supervisor}",
"next": None if not missing else "nap mcp install",
}
def mcp_runtime_probe(timeout_seconds=12):
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}
environment = os.environ.copy()
environment["NAPSEER_PROJECT_ROOT"] = str(pathlib.Path.cwd())
process = subprocess.Popen(
[sys.executable, str(supervisor)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=environment,
)
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)
if not ready:
raise TimeoutError("MCP probe timed out")
return json.loads(process.stdout.readline())
try:
initialized = request(1, "initialize", {
"protocolVersion": "2025-03-26",
"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"))
return {
"status": "ok" if initialized_ok and tools and read_ok else "failed",
"transport": bool(initialized_ok and tools),
"read": read_ok,
"tool_count": len(tools),
}
except Exception as exc:
return {
"status": "failed",
"transport": False,
"read": False,
"error_class": exc.__class__.__name__,
}
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
def handle_mcp(args):
subcommand = args[0] if args else "status"
rest = args[1:]
@@ -1026,6 +1104,13 @@ def doctor_status():
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":
issues.append({
"code": "mcp_fresh_probe_failed",
"message": "fresh MCP transport or authenticated read failed",
"next": "nap mcp update; then restart the MCP client",
})
return {
"status": "ok" if not issues else "repair_required",
"checks": {
@@ -1037,6 +1122,8 @@ def doctor_status():
"project_attached": bool(auth.get("project_id")),
"mcp_supervisor": runtime_asset_path("napseer_mcp_supervisor.py").is_file(),
"mcp_worker": runtime_asset_path("napseer_mcp_server.py").is_file(),
"mcp_fresh_probe": probe,
"client_connection": "not_observable",
},
"issues": issues,
}
@@ -1090,18 +1177,12 @@ def main(argv):
if command == "version":
print_json(version_status())
return 0
if command == "install":
print("`nap install` is deprecated; use `nap mcp install`.", file=sys.stderr)
print_json(install_assets())
return 0
if command == "update":
result = install_assets()
print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."})
return 0
if command == "gateway":
return handle_gateway(args)
if command == "chat":
return run_wrapper("chat", args or ["secret", "status"])
if command == "status":
return run_wrapper("configure", args)
if command == "project":
@@ -1110,38 +1191,23 @@ def main(argv):
if args[0] == "bootstrap":
raise RuntimeError("unknown project command: bootstrap")
return run_wrapper("project", args)
if command == "lineage":
return run_wrapper("lineage", args or ["status"])
if command == "plan":
return run_wrapper("plan", args or ["list"])
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
if args and args[0] == "repair":
return run_wrapper("auth", ["refresh", *args[1:]])
return run_wrapper("auth", ["repair", *args[1:]])
if args and args[0] == "refresh":
print("`nap auth refresh` is deprecated; use `nap auth repair`.", file=sys.stderr)
raise RuntimeError("unknown auth command: refresh; use `nap auth repair`")
return run_wrapper("auth", args)
if command == "feedback":
return run_wrapper("feedback", args)
if command == "where":
state = state_dir_status()
print_json(
{
"bin": shutil.which("nap") or str(BIN_DIR / "nap"),
"install_dir": str(INSTALL_DIR),
"wrapper": str(runtime_asset_path("napseer_mcp_server.py")),
"supervisor": str(runtime_asset_path("napseer_mcp_supervisor.py")),
"cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
**state,
"path_warning": path_warning(),
"status": "ok",
"message": f"Napseer install and cwd paths resolved. {state['state_dir_message']}",
}
)
return 0
removed = {
"install": "use `nap mcp install`",
"where": "use `nap doctor` or `nap mcp status`",
"chat": "use the authenticated MCP chat-secret tools",
"plan": "use the authenticated MCP planning tools",
"lineage": "use the authenticated MCP lineage tools",
"agent": "use the authenticated MCP agent tools",
"feedback": "use the authenticated MCP feedback tools",
}
if command in removed:
raise RuntimeError(f"unknown command: {command}; {removed[command]}")
raise RuntimeError(f"unknown command: {command}")