mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-12 04:01:35 +00:00
MCP's stdio transport uses stdout for JSON-RPC framing, so anything else written there corrupts every response after it. The original #1134 bug was progress-tracker output landing on stdout during tool calls that construct a `ContextGraph`, which is exactly what happens on any request that triggers reasoning or extraction. This PR closes out the remaining pieces of that fix: loading now goes through `load_from_file()` instead of the older `load()` path on the root graph, and mutations, `record_decision`, `add_entity`, `add_relationship`, now persist back to `SEMANTICA_KG_PATH` when it's configured, in both MCP server implementations (the root `mcp/` package and the packaged `semantica.mcp_server`), not just one. Four things came out of review on top of that. The stdio regression test originally exercised `get_graph_summary`, which doesn't touch the progress tracker at all, so it couldn't have caught the original bug. Swapped it for `run_reasoning`: `Reasoner.infer_with_results()` calls `progress_tracker.start_tracking()` directly, the exact call site that corrupted stdout before, so this is the minimal path that actually proves the fix. The test now spawns a real `python -m mcp` subprocess, sends it a `tools/call` for `run_reasoning`, and asserts every single line on stdout parses as JSON. Loading a corrupt or unreadable `SEMANTICA_KG_PATH` used to fail silently and fall through to an empty graph, which meant the next mutation would happily save that empty graph over the original file. Both implementations now track whether the initial load actually succeeded. If it didn't, every mutation handler refuses to save and returns an error instead, so a broken file on disk stays broken rather than getting silently replaced with nothing. An empty file is treated differently: that's a fresh destination, not a corrupt one, and starts a normal empty graph without tripping the guard. `save_to_file` used to `open(path, 'w')` and `json.dump` directly into the destination, so a crash or disk-full error mid-write could leave a truncated file as the only copy of the graph. It now writes to a temp file in the same directory, flushes, fsyncs, and only then `os.replace`s the destination, so the destination is always either the old contents or the new contents, never a partial write. The temp file gets cleaned up if anything fails before the replace. And since a mutation is applied to the in-memory graph before the save happens, a save failure used to leave the in-memory graph ahead of what's on disk, an entity or decision the client thinks succeeded but that never made it to the file. `record_decision`, `add_entity`, and `add_relationship` all roll back the in-memory mutation now if `save_to_file` raises, so the client-visible state and the persisted state never diverge: either both hold the change or neither does. 104 tests passing across the MCP, persistence, and progress-tracking suites.
76 lines
2.6 KiB
Python
76 lines
2.6 KiB
Python
"""
|
|
Shared graph session — lazy singleton across all tool handlers.
|
|
|
|
The graph is initialised once on first access and shared for the
|
|
lifetime of the MCP server process. Set SEMANTICA_KG_PATH to
|
|
automatically load a persisted graph on start.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any, Optional
|
|
|
|
log = logging.getLogger("semantica.mcp.session")
|
|
|
|
_graph: Optional[Any] = None
|
|
|
|
# Tracks whether the last graph initialisation successfully loaded the
|
|
# configured SEMANTICA_KG_PATH file. When True (or no path was configured)
|
|
# mutation handlers are allowed to save. When False an existing file failed
|
|
# to load; saving would overwrite the original data with an empty graph, so
|
|
# persistence is blocked until the process is restarted with a readable file.
|
|
_load_ok: bool = True
|
|
|
|
|
|
def get_graph() -> Any:
|
|
"""
|
|
Return the shared ContextGraph instance, creating it on first call.
|
|
|
|
The graph is created with advanced_analytics=True so all centrality,
|
|
community-detection, and embedding features are available.
|
|
"""
|
|
global _graph, _load_ok
|
|
if _graph is None:
|
|
from semantica.context import ContextGraph
|
|
|
|
_graph = ContextGraph(advanced_analytics=True)
|
|
_load_ok = True # default: safe to persist
|
|
|
|
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
|
|
if kg_path and os.path.exists(kg_path):
|
|
# Only attempt to load if the file has content. An empty file
|
|
# means the path was just created (e.g. a fresh tempfile) and
|
|
# should be treated as "start with empty graph" rather than a
|
|
# corrupt-file failure.
|
|
if os.path.getsize(kg_path) > 0:
|
|
try:
|
|
_graph.load_from_file(kg_path)
|
|
log.info("Graph loaded from %s", kg_path)
|
|
except Exception as exc:
|
|
log.warning(
|
|
"Could not load graph from %s: %s — persistence disabled "
|
|
"to protect existing data; restart the server to retry.",
|
|
kg_path, exc,
|
|
)
|
|
_load_ok = False # do not overwrite the original file
|
|
|
|
return _graph
|
|
|
|
|
|
def is_persistence_safe() -> bool:
|
|
"""Return True when it is safe to write mutations back to SEMANTICA_KG_PATH.
|
|
|
|
Returns False after a failed load so that mutation handlers do not
|
|
overwrite the original (possibly intact) file with a fresh empty graph.
|
|
"""
|
|
return _load_ok
|
|
|
|
|
|
def reset_graph() -> None:
|
|
"""Reset the singleton (mainly useful in tests)."""
|
|
global _graph, _load_ok
|
|
_graph = None
|
|
_load_ok = True
|