nexo-brain 7.9.6 → 7.9.7

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.6",
3
+ "version": "7.9.7",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.9.6` is the current packaged-runtime line. Patch release that closes continuity + MCP restart stability for installed users: Brain now persists conversation-scoped continuity snapshots and resume bundles, activates packaged runtimes atomically under `~/.nexo/core/current`, writes a durable `mcp-restart-required` marker on real version changes, and self-drains old MCP processes until the client restarts against the installed runtime. Coordinated Desktop v0.28.7 consumes the same contract so existing `brain-only` and `brain+desktop` installs reroute to the new runtime instead of running mixed old/new MCP code.
21
+ Version `7.9.7` is the current packaged-runtime line. Patch release that hardens the installed-user MCP/update path after live Desktop validation: Brain now normalizes managed runtime configs back to the stable `~/.nexo/core` root instead of leaving clients pinned to versioned snapshots, mirrors Claude Code MCP config into `~/.claude/mcp-cortex.json`, stamps the active MCP client in generated configs, and disables the FastMCP wrapped `output_schema` that was making Claude Code reject plain-text `nexo_*` tool results. Coordinated Desktop v0.28.8 consumes the same contract so archive/reopen/app-quit flows recover cleanly without reviving stale sessions.
22
22
 
23
23
  Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.6",
3
+ "version": "7.9.7",
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",
@@ -2262,19 +2262,20 @@ def _maybe_backfill_task_owner() -> None:
2262
2262
 
2263
2263
 
2264
2264
  def _f06_legacy_shim_map() -> list[tuple[str, Path]]:
2265
+ core_root = NEXO_HOME / "core"
2265
2266
  return [
2266
- ("scripts", NEXO_HOME / "core" / "scripts"),
2267
- ("skills-core", paths.core_skills_dir(allow_legacy_fallback=False)),
2268
- ("db", paths.core_db_dir(allow_legacy_fallback=False)),
2269
- ("cognitive", paths.core_cognitive_dir(allow_legacy_fallback=False)),
2270
- ("doctor", paths.core_doctor_dir(allow_legacy_fallback=False)),
2271
- ("dashboard", paths.core_dashboard_dir(allow_legacy_fallback=False)),
2267
+ ("scripts", core_root / "scripts"),
2268
+ ("skills-core", core_root / "skills"),
2269
+ ("db", core_root / "db"),
2270
+ ("cognitive", core_root / "cognitive"),
2271
+ ("doctor", core_root / "doctor"),
2272
+ ("dashboard", core_root / "dashboard"),
2272
2273
  ("brain", NEXO_HOME / "personal" / "brain"),
2273
2274
  ("config", NEXO_HOME / "personal" / "config"),
2274
2275
  ("skills", NEXO_HOME / "personal" / "skills"),
2275
- ("plugins", NEXO_HOME / "core" / "plugins"),
2276
- ("hooks", NEXO_HOME / "core" / "hooks"),
2277
- ("rules", NEXO_HOME / "core" / "rules"),
2276
+ ("plugins", core_root / "plugins"),
2277
+ ("hooks", core_root / "hooks"),
2278
+ ("rules", core_root / "rules"),
2278
2279
  ("data", NEXO_HOME / "runtime" / "data"),
2279
2280
  ("logs", NEXO_HOME / "runtime" / "logs"),
2280
2281
  ("operations", NEXO_HOME / "runtime" / "operations"),
@@ -2300,7 +2301,7 @@ def _f06_legacy_file_shim_map() -> list[tuple[str, Path]]:
2300
2301
  def _f06_packaged_code_file_targets() -> list[tuple[str, Path]]:
2301
2302
  names = list(_discover_runtime_root_python_modules(NEXO_HOME))
2302
2303
  names.extend(_discover_runtime_root_python_modules(NEXO_HOME / "core"))
2303
- for extra in ("requirements.txt", "model_defaults.json"):
2304
+ for extra in ("requirements.txt", "model_defaults.json", "package.json", "version.json"):
2304
2305
  candidate = NEXO_HOME / extra
2305
2306
  canonical_candidate = NEXO_HOME / "core" / extra
2306
2307
  if candidate.exists() or canonical_candidate.exists():
@@ -2422,12 +2423,13 @@ def _promote_packaged_runtime_code_to_core() -> None:
2422
2423
  return resolved
2423
2424
  return path
2424
2425
 
2426
+ core_root = NEXO_HOME / "core"
2425
2427
  dir_targets = [
2426
- ("db", paths.core_db_dir(allow_legacy_fallback=False)),
2427
- ("cognitive", paths.core_cognitive_dir(allow_legacy_fallback=False)),
2428
- ("doctor", paths.core_doctor_dir(allow_legacy_fallback=False)),
2429
- ("dashboard", paths.core_dashboard_dir(allow_legacy_fallback=False)),
2430
- ("skills-core", paths.core_skills_dir(allow_legacy_fallback=False)),
2428
+ ("db", core_root / "db"),
2429
+ ("cognitive", core_root / "cognitive"),
2430
+ ("doctor", core_root / "doctor"),
2431
+ ("dashboard", core_root / "dashboard"),
2432
+ ("skills-core", core_root / "skills"),
2431
2433
  ]
2432
2434
 
2433
2435
  for legacy_name, canonical in dir_targets:
@@ -2904,11 +2906,11 @@ def _maybe_migrate_to_f06_layout() -> None:
2904
2906
  ("state", NEXO_HOME / "runtime" / "state"),
2905
2907
  ("backups", NEXO_HOME / "runtime" / "backups"),
2906
2908
  ("memory", NEXO_HOME / "runtime" / "memory"),
2907
- ("cognitive", paths.core_cognitive_dir(allow_legacy_fallback=False)),
2909
+ ("cognitive", NEXO_HOME / "core" / "cognitive"),
2908
2910
  ("coordination", NEXO_HOME / "runtime" / "coordination"),
2909
2911
  ("exports", NEXO_HOME / "runtime" / "exports"),
2910
2912
  ("nexo-email", NEXO_HOME / "runtime" / "nexo-email"),
2911
- ("doctor", paths.core_doctor_dir(allow_legacy_fallback=False)),
2913
+ ("doctor", NEXO_HOME / "core" / "doctor"),
2912
2914
  ("snapshots", NEXO_HOME / "runtime" / "snapshots"),
2913
2915
  ("crons", NEXO_HOME / "runtime" / "crons"),
2914
2916
  ("skills", NEXO_HOME / "personal" / "skills"),
@@ -3955,19 +3957,19 @@ def _resolve_sync_source() -> tuple[Path | None, Path | None]:
3955
3957
  dest = NEXO_HOME
3956
3958
 
3957
3959
  def _runtime_version_source() -> Path | None:
3958
- version_file = NEXO_HOME / "version.json"
3959
- if not version_file.is_file():
3960
- return None
3961
- try:
3962
- data = json.loads(version_file.read_text())
3963
- except Exception:
3964
- return None
3965
- source = str(data.get("source", "")).strip()
3966
- if not source:
3967
- return None
3968
- candidate = Path(source).expanduser()
3969
- if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
3970
- return candidate
3960
+ for version_file in (NEXO_HOME / "version.json", NEXO_HOME / "core" / "version.json"):
3961
+ if not version_file.is_file():
3962
+ continue
3963
+ try:
3964
+ data = json.loads(version_file.read_text())
3965
+ except Exception:
3966
+ continue
3967
+ source = str(data.get("source", "")).strip()
3968
+ if not source:
3969
+ continue
3970
+ candidate = Path(source).expanduser()
3971
+ if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
3972
+ return candidate
3971
3973
  return None
3972
3974
 
3973
3975
  try:
@@ -27,6 +27,14 @@ def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
27
27
  parent = module_dir.parent / "templates"
28
28
  if parent.is_dir():
29
29
  return parent
30
+ try:
31
+ nexo_home_templates = resolve_nexo_home(
32
+ os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))
33
+ ) / "templates"
34
+ except Exception:
35
+ nexo_home_templates = Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser() / "templates"
36
+ if nexo_home_templates.is_dir():
37
+ return nexo_home_templates
30
38
  return direct
31
39
 
32
40
 
@@ -143,6 +143,45 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
143
143
  return DEFAULT_ASSISTANT_NAME
144
144
 
145
145
 
146
+ def _is_relative_to(path: Path, base: Path) -> bool:
147
+ try:
148
+ path.relative_to(base)
149
+ return True
150
+ except Exception:
151
+ return False
152
+
153
+
154
+ def _normalize_managed_runtime_root(nexo_home: Path, candidate: Path) -> Path:
155
+ raw = candidate.expanduser()
156
+ if raw.name == "server.py":
157
+ raw = raw.parent
158
+ try:
159
+ resolved = raw.resolve()
160
+ except Exception:
161
+ resolved = raw.absolute()
162
+ core_root = (nexo_home / "core").expanduser()
163
+ current_root = core_root / "current"
164
+ preferred_targets = []
165
+ if (core_root / "server.py").is_file():
166
+ preferred_targets.append(core_root)
167
+ if (current_root / "server.py").is_file():
168
+ preferred_targets.append(current_root)
169
+ if not preferred_targets:
170
+ return resolved
171
+ try:
172
+ core_resolved = core_root.resolve()
173
+ except Exception:
174
+ core_resolved = core_root.absolute()
175
+ version_root = core_resolved / "versions"
176
+ try:
177
+ current_resolved = current_root.resolve()
178
+ except Exception:
179
+ current_resolved = current_root.absolute()
180
+ if resolved == current_resolved or _is_relative_to(resolved, version_root):
181
+ return preferred_targets[0]
182
+ return resolved
183
+
184
+
146
185
  def _resolve_runtime_root(nexo_home: Path, runtime_root: str | os.PathLike[str] | None = None) -> Path:
147
186
  candidates: list[Path] = []
148
187
  if runtime_root:
@@ -155,12 +194,13 @@ def _resolve_runtime_root(nexo_home: Path, runtime_root: str | os.PathLike[str]
155
194
 
156
195
  seen: set[Path] = set()
157
196
  for candidate in candidates:
158
- resolved = candidate.resolve()
197
+ normalized = _normalize_managed_runtime_root(nexo_home, candidate)
198
+ resolved = normalized.resolve()
159
199
  if resolved in seen:
160
200
  continue
161
201
  seen.add(resolved)
162
- if (resolved / "server.py").is_file():
163
- return resolved
202
+ if (normalized / "server.py").is_file():
203
+ return normalized
164
204
  raise FileNotFoundError(f"Could not locate runtime root with server.py (tried {len(seen)} locations)")
165
205
 
166
206
 
@@ -409,6 +449,7 @@ def build_server_config(
409
449
  runtime_root: str | os.PathLike[str] | None = None,
410
450
  python_path: str = "",
411
451
  operator_name: str = "",
452
+ client: str = "",
412
453
  ) -> dict:
413
454
  nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
414
455
  runtime_root_path = _resolve_runtime_root(nexo_home_path, runtime_root)
@@ -423,6 +464,9 @@ def build_server_config(
423
464
  resolved_name = _resolve_operator_name(nexo_home_path, explicit=operator_name)
424
465
  if resolved_name:
425
466
  config["env"]["NEXO_NAME"] = resolved_name
467
+ resolved_client = normalize_client_key(client)
468
+ if resolved_client:
469
+ config["env"]["NEXO_MCP_CLIENT"] = resolved_client
426
470
  return config
427
471
 
428
472
 
@@ -436,6 +480,11 @@ def _claude_code_mcp_path(home: Path | None = None) -> Path:
436
480
  return base / ".claude.json"
437
481
 
438
482
 
483
+ def _claude_code_cortex_path(home: Path | None = None) -> Path:
484
+ base = home or _user_home()
485
+ return base / ".claude" / "mcp-cortex.json"
486
+
487
+
439
488
  def _claude_desktop_config_path(home: Path | None = None) -> Path:
440
489
  base = home or _user_home()
441
490
  if sys.platform == "darwin":
@@ -1022,6 +1071,7 @@ def sync_claude_code(
1022
1071
  runtime_root=runtime_root,
1023
1072
  python_path=python_path,
1024
1073
  operator_name=operator_name,
1074
+ client="claude_code",
1025
1075
  )
1026
1076
  home_path = Path(user_home).expanduser() if user_home else None
1027
1077
  result = _sync_claude_code_settings(
@@ -1039,6 +1089,13 @@ def sync_claude_code(
1039
1089
  )
1040
1090
  result["mcp"] = mcp_result
1041
1091
  result["mcp_path"] = mcp_result.get("path", "")
1092
+ cortex_result = _sync_json_client(
1093
+ _claude_code_cortex_path(home_path),
1094
+ server_config,
1095
+ "claude_code",
1096
+ )
1097
+ result["cortex_mcp"] = cortex_result
1098
+ result["cortex_mcp_path"] = cortex_result.get("path", "")
1042
1099
  bootstrap_result = sync_client_bootstrap(
1043
1100
  "claude_code",
1044
1101
  nexo_home=nexo_home,
@@ -1066,6 +1123,7 @@ def sync_claude_desktop(
1066
1123
  runtime_root=runtime_root,
1067
1124
  python_path=python_path,
1068
1125
  operator_name=operator_name,
1126
+ client="claude_desktop",
1069
1127
  )
1070
1128
  resolved_name = server_config.get("env", {}).get("NEXO_NAME", "") or _resolve_operator_name(
1071
1129
  Path(nexo_home).expanduser() if nexo_home else _default_nexo_home(),
@@ -1097,6 +1155,7 @@ def sync_codex(
1097
1155
  runtime_root=runtime_root,
1098
1156
  python_path=python_path,
1099
1157
  operator_name=operator_name,
1158
+ client="codex",
1100
1159
  )
1101
1160
  codex_bin = shutil.which("codex")
1102
1161
  config_path = _codex_config_path(home_path)
@@ -10,15 +10,27 @@ from pathlib import Path
10
10
  _TOKEN_RE = re.compile(r"\[\[([a-zA-Z0-9_]+)\]\]")
11
11
 
12
12
 
13
+ def _find_templates_root(start: Path) -> Path | None:
14
+ current = start.expanduser()
15
+ try:
16
+ current = current.resolve()
17
+ except Exception:
18
+ current = current.absolute()
19
+ if current.is_file():
20
+ current = current.parent
21
+ for candidate in (current, *current.parents):
22
+ if (candidate / "templates" / "core-prompts").is_dir():
23
+ return candidate
24
+ return None
25
+
26
+
13
27
  def _resolve_repo_root() -> Path:
14
- candidate = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent))).expanduser().resolve()
15
- if candidate.name == "src":
16
- return candidate.parent
17
- if (candidate / "templates").is_dir():
18
- return candidate
19
- if (candidate.parent / "templates").is_dir():
20
- return candidate.parent
21
- return Path(__file__).resolve().parents[1]
28
+ configured = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
29
+ resolved = _find_templates_root(configured)
30
+ if resolved is not None:
31
+ return resolved
32
+ fallback = Path(__file__).resolve().parents[1]
33
+ return _find_templates_root(fallback) or fallback
22
34
 
23
35
 
24
36
  PROMPTS_DIR = _resolve_repo_root() / "templates" / "core-prompts"
@@ -75,6 +75,7 @@ TERMINAL_STATUSES = {
75
75
  "rejected",
76
76
  }
77
77
  _DIARY_TRIGGERING = lifecycle_prompts.DIARY_TRIGGERING_ACTIONS
78
+ SESSION_NOT_LINKED_REASON = "session-not-linked-to-nexo"
78
79
 
79
80
 
80
81
  def _normalise_payload(obj: Any) -> str:
@@ -125,6 +126,34 @@ def _session_diary_session_ids(conn, session_id: str) -> List[str]:
125
126
  return deduped
126
127
 
127
128
 
129
+ def _session_is_linked_to_nexo(conn, session_id: str) -> bool:
130
+ """True when the external Claude/Desktop session is linked to a NEXO SID."""
131
+ raw = str(session_id or "").strip()
132
+ if not raw:
133
+ return False
134
+ if raw.startswith("nexo-"):
135
+ return True
136
+ try:
137
+ row = conn.execute(
138
+ "SELECT 1 FROM session_claude_aliases WHERE claude_session_id = ? LIMIT 1",
139
+ (raw,),
140
+ ).fetchone()
141
+ if row is not None:
142
+ return True
143
+ except Exception:
144
+ pass
145
+ try:
146
+ row = conn.execute(
147
+ "SELECT 1 FROM sessions "
148
+ "WHERE external_session_id = ? OR claude_session_id = ? "
149
+ "LIMIT 1",
150
+ (raw, raw),
151
+ ).fetchone()
152
+ return row is not None
153
+ except Exception:
154
+ return False
155
+
156
+
128
157
  def _max_session_diary_id(conn, session_id: str) -> int:
129
158
  session_ids = _session_diary_session_ids(conn, session_id)
130
159
  if not session_ids:
@@ -510,10 +539,14 @@ def record_complete_canonical(
510
539
  )
511
540
  diary_evidence = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
512
541
  diary_required = action in _DIARY_TRIGGERING and bool(session_id)
542
+ session_registered = _session_is_linked_to_nexo(conn, session_id) if diary_required else bool(session_id)
543
+ session_unregistered = diary_required and not session_registered
513
544
  diary_missing = diary_required and diary_evidence is None
514
545
  effective = "retryable_error" if (any_failure or diary_missing) else "canonical_done"
515
546
  last_error = None
516
- if any_failure:
547
+ if session_unregistered:
548
+ last_error = SESSION_NOT_LINKED_REASON
549
+ elif any_failure:
517
550
  last_error = "one-or-more-actions-failed"
518
551
  elif diary_missing:
519
552
  last_error = "canonical-diary-not-confirmed"
@@ -540,8 +573,9 @@ def record_complete_canonical(
540
573
  "failed_actions": any_failure,
541
574
  "diary_confirmed": diary_evidence is not None,
542
575
  "diary_required": diary_required,
576
+ "session_registered": session_registered,
543
577
  "session_diary_id": diary_evidence.get("session_diary_id") if diary_evidence else None,
544
- "reason": "canonical-diary-not-confirmed" if diary_missing else None,
578
+ "reason": last_error if effective == "retryable_error" else None,
545
579
  }
546
580
 
547
581
 
@@ -570,6 +604,15 @@ def wait_for_canonical_diary(
570
604
  session_id = str(row[0] or "")
571
605
  if not session_id:
572
606
  return {"status": "rejected", "reason": "missing-session-id", "event_id": event_id}
607
+ if not _session_is_linked_to_nexo(conn, session_id):
608
+ return {
609
+ "status": "retryable_error",
610
+ "event_id": event_id,
611
+ "session_id": session_id,
612
+ "diary_confirmed": False,
613
+ "session_registered": False,
614
+ "reason": SESSION_NOT_LINKED_REASON,
615
+ }
573
616
  evidence = _session_diary_evidence(conn, session_id, row[1], row[2])
574
617
  if evidence is not None:
575
618
  return {
@@ -577,6 +620,7 @@ def wait_for_canonical_diary(
577
620
  "event_id": event_id,
578
621
  "session_id": session_id,
579
622
  "diary_confirmed": True,
623
+ "session_registered": True,
580
624
  **evidence,
581
625
  }
582
626
  if time.monotonic() >= deadline:
@@ -585,6 +629,7 @@ def wait_for_canonical_diary(
585
629
  "event_id": event_id,
586
630
  "session_id": session_id,
587
631
  "diary_confirmed": False,
632
+ "session_registered": True,
588
633
  "reason": last_error or "diary-confirm-timeout",
589
634
  }
590
635
  time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
package/src/paths.py CHANGED
@@ -71,6 +71,19 @@ def home() -> Path:
71
71
  # ---------------------------------------------------------------------------
72
72
  def core_dir() -> Path:
73
73
  container = home() / "core"
74
+ live_markers = (
75
+ "cli.py",
76
+ "server.py",
77
+ "db",
78
+ "hooks",
79
+ "plugins",
80
+ "rules",
81
+ "scripts",
82
+ "package.json",
83
+ "version.json",
84
+ )
85
+ if any((container / marker).exists() for marker in live_markers):
86
+ return container
74
87
  current = container / "current"
75
88
  if current.exists():
76
89
  try:
@@ -290,7 +290,13 @@ def load_plugin(mcp, filename: str, plugins_dir: str | None = None) -> int:
290
290
  mcp.local_provider.remove_tool(name)
291
291
  except Exception:
292
292
  pass
293
- t = Tool.from_function(func, name=name, description=description)
293
+ # output_schema=None disables FastMCP's auto-generated
294
+ # `x-fastmcp-wrap-result` wrapper that otherwise makes str-returning
295
+ # plugin tools unexecutable in Claude Code. See server.py and
296
+ # followup NF-FASTMCP-OUTPUT-SCHEMA-1776969764.
297
+ t = Tool.from_function(
298
+ func, name=name, description=description, output_schema=None
299
+ )
294
300
  mcp.add_tool(t)
295
301
  tool_names.append(name)
296
302
 
@@ -158,7 +158,7 @@ def write_restart_required_marker(
158
158
  def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, version: str = "") -> dict:
159
159
  container = core_container_dir()
160
160
  source = Path(source_root or container)
161
- if source == container and core_current_link().exists():
161
+ if source_root is None and source == container and core_current_link().exists():
162
162
  try:
163
163
  source = core_current_link().resolve(strict=False)
164
164
  except Exception:
@@ -319,6 +319,28 @@ def prime_process_version() -> str:
319
319
  class RestartRequiredMiddleware(Middleware):
320
320
  client: str = ""
321
321
 
322
+ async def _tool_result_for_restart_required(self, context, payload: dict) -> ToolResult:
323
+ payload_text = json.dumps(payload, ensure_ascii=False)
324
+ tool = None
325
+ try:
326
+ fastmcp_context = getattr(context, "fastmcp_context", None)
327
+ fastmcp_server = getattr(fastmcp_context, "fastmcp", None)
328
+ if fastmcp_server is not None:
329
+ tool = await fastmcp_server.get_tool(str(getattr(context.message, "name", "") or "").strip())
330
+ except Exception:
331
+ tool = None
332
+
333
+ output_schema = getattr(tool, "output_schema", None)
334
+ if isinstance(output_schema, dict) and output_schema.get("x-fastmcp-wrap-result"):
335
+ return ToolResult(
336
+ content=payload_text,
337
+ structured_content={"result": payload_text},
338
+ )
339
+ return ToolResult(
340
+ content=payload_text,
341
+ structured_content=payload,
342
+ )
343
+
322
344
  async def on_call_tool(self, context, call_next):
323
345
  tool_name = str(getattr(context.message, "name", "") or "").strip()
324
346
  state = resolve_restart_required(client=self.client)
@@ -336,7 +358,4 @@ class RestartRequiredMiddleware(Middleware):
336
358
  "reason": state["reason"],
337
359
  "client_action": state["client_action"],
338
360
  }
339
- return ToolResult(
340
- content=json.dumps(payload, ensure_ascii=False),
341
- structured_content=payload,
342
- )
361
+ return await self._tool_result_for_restart_required(context, payload)
package/src/server.py CHANGED
@@ -280,6 +280,25 @@ mcp.add_middleware(
280
280
  RestartRequiredMiddleware(client=str(os.environ.get("NEXO_MCP_CLIENT", "") or "").strip())
281
281
  )
282
282
 
283
+ # FastMCP (both 2.14.7 and 3.2.4) auto-generates an outputSchema wrapper
284
+ # (`{required: [result], x-fastmcp-wrap-result: true}`) for every tool whose
285
+ # return annotation is a non-object type such as `str`. Claude Code validates
286
+ # MCP tool responses strictly against outputSchema and rejects our plain-text
287
+ # replies with `Output validation error: result is a required property`,
288
+ # which makes every `nexo_*` tool inexecutable from Claude Code. We opt out
289
+ # of output_schema globally by wrapping `mcp.tool` so every decorator here
290
+ # and in plugins defaults to `output_schema=None` unless the caller passes
291
+ # something explicit. See followup NF-FASTMCP-OUTPUT-SCHEMA-1776969764.
292
+ _mcp_tool_original = mcp.tool
293
+
294
+
295
+ def _mcp_tool_without_output_schema(name_or_fn=None, **kwargs):
296
+ kwargs.setdefault("output_schema", None)
297
+ return _mcp_tool_original(name_or_fn, **kwargs)
298
+
299
+
300
+ mcp.tool = _mcp_tool_without_output_schema # type: ignore[method-assign]
301
+
283
302
 
284
303
  def _run_kwargs_from_env() -> dict:
285
304
  transport = str(os.environ.get("NEXO_MCP_TRANSPORT", "stdio") or "stdio").strip().lower()