nexo-brain 7.20.19 → 7.20.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.py +167 -0
  3. package/src/server.py +63 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.19",
3
+ "version": "7.20.20",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
package/src/cli.py CHANGED
@@ -140,6 +140,166 @@ def _mcp_status(args) -> int:
140
140
  )
141
141
 
142
142
 
143
+ def _mcp_write_message(stdin, payload: dict) -> None:
144
+ raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
145
+ stdin.write(raw + b"\n")
146
+ stdin.flush()
147
+
148
+
149
+ def _mcp_reader(stdout, queue) -> None:
150
+ while True:
151
+ try:
152
+ line = stdout.readline()
153
+ if not line:
154
+ return
155
+ text = line.decode("utf-8", errors="replace").strip()
156
+ if not text:
157
+ continue
158
+ queue.put(json.loads(text))
159
+ except Exception as exc:
160
+ queue.put({"_reader_error": str(exc)})
161
+ return
162
+
163
+
164
+ def _stderr_reader(stderr, lines: list[str]) -> None:
165
+ while True:
166
+ chunk = stderr.readline()
167
+ if not chunk:
168
+ return
169
+ try:
170
+ lines.append(chunk.decode("utf-8", errors="replace").rstrip())
171
+ except Exception:
172
+ lines.append(repr(chunk))
173
+ del lines[:-80]
174
+
175
+
176
+ def _mcp_wait_for_response(queue, request_id: int, timeout_seconds: float) -> dict:
177
+ import queue as queue_module
178
+
179
+ deadline = time.monotonic() + max(timeout_seconds, 0.1)
180
+ while time.monotonic() < deadline:
181
+ remaining = max(deadline - time.monotonic(), 0.05)
182
+ try:
183
+ message = queue.get(timeout=remaining)
184
+ except queue_module.Empty:
185
+ break
186
+ if message.get("_reader_error"):
187
+ raise RuntimeError(message["_reader_error"])
188
+ if message.get("id") == request_id:
189
+ return message
190
+ raise TimeoutError(f"MCP response {request_id} timed out")
191
+
192
+
193
+ def _mcp_probe(args) -> int:
194
+ import queue as queue_module
195
+ import threading
196
+
197
+ timeout_ms = int(getattr(args, "timeout_ms", 8000) or 8000)
198
+ timeout_seconds = max(timeout_ms / 1000.0, 1.0)
199
+ started_at = time.monotonic()
200
+ server_path = NEXO_CODE / "server.py"
201
+ env = os.environ.copy()
202
+ env["NEXO_MCP_PROBE"] = "1"
203
+ env.setdefault("NEXO_MCP_PLUGIN_MODE", getattr(args, "plugin_mode", None) or "none")
204
+ env.setdefault("NEXO_MCP_RUN_STARTUP_PREFLIGHT", "0")
205
+ client = str(getattr(args, "client", "") or "").strip()
206
+ if client:
207
+ env["NEXO_MCP_CLIENT"] = client
208
+
209
+ proc = None
210
+ stderr_lines: list[str] = []
211
+ try:
212
+ proc = subprocess.Popen(
213
+ [sys.executable, str(server_path)],
214
+ cwd=str(NEXO_CODE),
215
+ env=env,
216
+ stdin=subprocess.PIPE,
217
+ stdout=subprocess.PIPE,
218
+ stderr=subprocess.PIPE,
219
+ text=False,
220
+ )
221
+ responses = queue_module.Queue()
222
+ threading.Thread(target=_mcp_reader, args=(proc.stdout, responses), daemon=True).start()
223
+ threading.Thread(target=_stderr_reader, args=(proc.stderr, stderr_lines), daemon=True).start()
224
+
225
+ _mcp_write_message(proc.stdin, {
226
+ "jsonrpc": "2.0",
227
+ "id": 1,
228
+ "method": "initialize",
229
+ "params": {
230
+ "protocolVersion": "2024-11-05",
231
+ "capabilities": {},
232
+ "clientInfo": {"name": "nexo-mcp-probe", "version": _get_version()},
233
+ },
234
+ })
235
+ init_response = _mcp_wait_for_response(responses, 1, timeout_seconds)
236
+ if init_response.get("error"):
237
+ raise RuntimeError(f"MCP initialize failed: {init_response['error']}")
238
+
239
+ _mcp_write_message(proc.stdin, {
240
+ "jsonrpc": "2.0",
241
+ "method": "notifications/initialized",
242
+ "params": {},
243
+ })
244
+ _mcp_write_message(proc.stdin, {
245
+ "jsonrpc": "2.0",
246
+ "id": 2,
247
+ "method": "tools/list",
248
+ "params": {},
249
+ })
250
+ tools_response = _mcp_wait_for_response(responses, 2, timeout_seconds)
251
+ if tools_response.get("error"):
252
+ raise RuntimeError(f"MCP tools/list failed: {tools_response['error']}")
253
+ tools = ((tools_response.get("result") or {}).get("tools") or [])
254
+ tool_names = [
255
+ str(tool.get("name") or "")
256
+ for tool in tools
257
+ if isinstance(tool, dict) and tool.get("name")
258
+ ]
259
+ required = ["nexo_startup", "nexo_heartbeat", "nexo_task_open", "nexo_guard_check"]
260
+ missing = [name for name in required if name not in tool_names]
261
+ ok = not missing and len(tool_names) > 0
262
+ payload = {
263
+ "ok": ok,
264
+ "mcp_ready": ok,
265
+ "probe_ok": ok,
266
+ "tools_available": len(tool_names) > 0,
267
+ "tool_count": len(tool_names),
268
+ "required_tools_present": not missing,
269
+ "missing_required_tools": missing,
270
+ "client": client,
271
+ "plugin_mode": env.get("NEXO_MCP_PLUGIN_MODE"),
272
+ "elapsed_ms": int((time.monotonic() - started_at) * 1000),
273
+ "stderr_tail": "\n".join(stderr_lines[-12:]),
274
+ }
275
+ return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
276
+ except Exception as exc:
277
+ payload = {
278
+ "ok": False,
279
+ "mcp_ready": False,
280
+ "probe_ok": False,
281
+ "error": "mcp_probe_failed",
282
+ "message": str(exc),
283
+ "client": client,
284
+ "elapsed_ms": int((time.monotonic() - started_at) * 1000),
285
+ "stderr_tail": "\n".join(stderr_lines[-20:]),
286
+ }
287
+ return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
288
+ finally:
289
+ if proc is not None:
290
+ try:
291
+ proc.terminate()
292
+ except Exception:
293
+ pass
294
+ try:
295
+ proc.wait(timeout=2)
296
+ except Exception:
297
+ try:
298
+ proc.kill()
299
+ except Exception:
300
+ pass
301
+
302
+
143
303
  def _mcp_clear_restart(args) -> int:
144
304
  return _print_json_or_text(
145
305
  clear_restart_required_marker(
@@ -3562,6 +3722,11 @@ def main():
3562
3722
  mcp_status_p = mcp_sub.add_parser("status", help="Read the current runtime/MCP alignment state")
3563
3723
  mcp_status_p.add_argument("--client", default="", help="Optional client label such as claude_desktop or codex")
3564
3724
  mcp_status_p.add_argument("--json", action="store_true", help="JSON output")
3725
+ mcp_probe_p = mcp_sub.add_parser("probe", help="Launch the MCP server and verify initialize + tools/list")
3726
+ mcp_probe_p.add_argument("--client", default="", help="Optional client label such as claude_desktop or codex")
3727
+ mcp_probe_p.add_argument("--timeout-ms", type=int, default=8000)
3728
+ mcp_probe_p.add_argument("--plugin-mode", default="none", choices=["essential", "none", "full"])
3729
+ mcp_probe_p.add_argument("--json", action="store_true", help="JSON output")
3565
3730
  mcp_clear_p = mcp_sub.add_parser("clear-restart", help="Acknowledge that a client/session reloaded the new runtime")
3566
3731
  mcp_clear_p.add_argument("--client", default="", help="Client label such as claude_desktop or codex")
3567
3732
  mcp_clear_p.add_argument("--installed-version", default="")
@@ -3865,6 +4030,8 @@ def main():
3865
4030
  elif args.command == "mcp":
3866
4031
  if args.mcp_command == "status":
3867
4032
  return _mcp_status(args)
4033
+ if args.mcp_command == "probe":
4034
+ return _mcp_probe(args)
3868
4035
  if args.mcp_command == "clear-restart":
3869
4036
  return _mcp_clear_restart(args)
3870
4037
  mcp_parser.print_help()
package/src/server.py CHANGED
@@ -259,8 +259,56 @@ def _run_startup_preflight_sync() -> None:
259
259
  print(f"[NEXO auto-update] error: {e}", file=sys.stderr)
260
260
 
261
261
 
262
+ _ESSENTIAL_MCP_STARTUP_PLUGINS = (
263
+ "cards.py",
264
+ "doctor.py",
265
+ "episodic_memory.py",
266
+ "evolution.py",
267
+ "lifecycle_events.py",
268
+ "outcomes.py",
269
+ "preferences.py",
270
+ "protocol.py",
271
+ "recover.py",
272
+ "skills.py",
273
+ "user_state_tools.py",
274
+ "workflow.py",
275
+ )
276
+
277
+
278
+ def _env_flag(name: str, *, default: bool = False) -> bool:
279
+ value = os.environ.get(name)
280
+ if value is None:
281
+ return default
282
+ return str(value).strip().lower() in {"1", "true", "yes", "on", "y", "si"}
283
+
284
+
285
+ def _mcp_startup_plugin_mode() -> str:
286
+ return str(os.environ.get("NEXO_MCP_PLUGIN_MODE", "none") or "none").strip().lower()
287
+
288
+
289
+ def _load_startup_plugins() -> None:
290
+ mode = _mcp_startup_plugin_mode()
291
+ if mode in {"none", "off", "0", "false"}:
292
+ print("[NEXO] MCP dynamic plugin loading skipped.", file=sys.stderr)
293
+ return
294
+ if mode in {"full", "all", "legacy"}:
295
+ load_all_plugins(mcp)
296
+ return
297
+
298
+ if mode not in {"essential", "fast", "default"}:
299
+ print(f"[NEXO] Unknown NEXO_MCP_PLUGIN_MODE={mode!r}; using essential plugins.", file=sys.stderr)
300
+
301
+ loaded = 0
302
+ for filename in _ESSENTIAL_MCP_STARTUP_PLUGINS:
303
+ try:
304
+ loaded += int(load_plugin(mcp, filename) or 0)
305
+ except Exception as exc:
306
+ print(f"[PLUGIN ERROR] {filename}: {exc}", file=sys.stderr)
307
+ print(f"[NEXO] MCP essential plugins ready: {loaded} tools.", file=sys.stderr)
308
+
309
+
262
310
  def _server_init():
263
- """Run all side effects: signals, PID, DB, auto-update, plugins.
311
+ """Run side effects needed by the MCP server.
264
312
 
265
313
  Called only when the server is actually started (not on import).
266
314
  """
@@ -268,20 +316,26 @@ def _server_init():
268
316
  signal.signal(signal.SIGINT, _shutdown_handler)
269
317
 
270
318
  # ── Write PID file for stale process detection ─────────────────
271
- data_dir = _data_dir()
272
- os.makedirs(data_dir, exist_ok=True)
273
- _pid_file = os.path.join(data_dir, "nexo.pid")
274
- with open(_pid_file, "w") as f:
275
- f.write(str(os.getpid()))
319
+ if not _env_flag("NEXO_MCP_PROBE"):
320
+ data_dir = _data_dir()
321
+ os.makedirs(data_dir, exist_ok=True)
322
+ _pid_file = os.path.join(data_dir, "nexo.pid")
323
+ with open(_pid_file, "w") as f:
324
+ f.write(str(os.getpid()))
276
325
 
277
326
  # ── Database initialization with recovery ─────────────────────
278
327
  _init_db_or_exit()
279
328
 
280
- # ── Auto-update / startup preflight (synchronous) ─────────────
281
- _run_startup_preflight_sync()
329
+ # ── Auto-update / startup preflight ───────────────────────────
330
+ # The MCP client waits for an immediate JSON-RPC handshake. Running update
331
+ # checks here can block the transport and make clients start without NEXO.
332
+ if _env_flag("NEXO_MCP_RUN_STARTUP_PREFLIGHT"):
333
+ _run_startup_preflight_sync()
334
+ else:
335
+ print("[NEXO] MCP startup preflight deferred.", file=sys.stderr)
282
336
 
283
337
  # ── Load plugins ───────────────────────────────────────────────
284
- load_all_plugins(mcp)
338
+ _load_startup_plugins()
285
339
 
286
340
 
287
341
  mcp = FastMCP(