mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(mcp): repair standalone export_graph
This commit is contained in:
@@ -21,6 +21,17 @@ Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
|
||||
}
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# MCP stdio framing IS stdout: any progress bar or console renderer that writes
|
||||
# to stdout would interleave with the JSON-RPC stream and corrupt framing for
|
||||
# every client. This package is always used as an MCP stdio server, so force
|
||||
# progress tracking off for the entire process. Set before importing server /
|
||||
# tools so the Semantica progress-tracker singleton is never created with
|
||||
# output enabled (the singleton reads this variable at construction time and
|
||||
# the enabled.setter re-checks it, so later re-enable attempts are also blocked).
|
||||
os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
|
||||
|
||||
# `semantica.__version__` is the authoritative package version — see
|
||||
# semantica/mcp_server/__init__.py for why it is used directly rather than
|
||||
# importlib.metadata.version("semantica").
|
||||
|
||||
+6
-1
@@ -80,7 +80,12 @@ def handle_export_graph(args: dict) -> dict:
|
||||
if rdf_fmt:
|
||||
try:
|
||||
from semantica.export import RDFExporter
|
||||
rdf_str = RDFExporter().export_to_rdf(graph, format=rdf_fmt)
|
||||
# RDFExporter.export_to_rdf() expects the canonical kg dict
|
||||
# {"entities": [...], "relationships": [...]}, not a ContextGraph
|
||||
# object. Convert before handing off; passing the raw graph
|
||||
# caused AttributeError: 'ContextGraph' object has no attribute
|
||||
# 'get' on every RDF format.
|
||||
rdf_str = RDFExporter().export_to_rdf(graph.to_kg_dict(), format=rdf_fmt)
|
||||
return {"format": rdf_fmt, "data": rdf_str}
|
||||
except Exception as exc:
|
||||
return {"error": f"RDF export failed: {exc}"}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Regression tests for the standalone mcp/ package export_graph tool.
|
||||
|
||||
The mcp/ server (python -m mcp / python -m mcp.server) had two failures on
|
||||
every RDF export format:
|
||||
|
||||
1. AttributeError: 'ContextGraph' object has no attribute 'get'
|
||||
handle_export_graph() in mcp/tools/export.py called
|
||||
RDFExporter().export_to_rdf(graph, ...) passing the raw ContextGraph
|
||||
object instead of the canonical kg dict expected by the exporter.
|
||||
|
||||
2. stdout progress corruption
|
||||
RDFExporter.__init__ instantiated the Semantica progress-tracker
|
||||
singleton, which wrote a progress bar to sys.stdout before the
|
||||
AttributeError was raised. stdout is the MCP stdio JSON-RPC transport,
|
||||
so this interleaved non-JSON bytes corrupted framing for every client.
|
||||
|
||||
Fixes applied:
|
||||
- mcp/tools/export.py: convert with graph.to_kg_dict() before export_to_rdf()
|
||||
- mcp/__init__.py: os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1" at
|
||||
package initialisation, before any tool handler can instantiate
|
||||
RDFExporter and therefore before the tracker singleton is created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
import semantica.utils.progress_tracker as _progress_module
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_graph():
|
||||
"""Return a ContextGraph with two entities and one relationship."""
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("n1", node_type="entity")
|
||||
g.add_node("n2", node_type="entity")
|
||||
g.add_edge("n1", "n2", "related_to")
|
||||
return g
|
||||
|
||||
|
||||
def _reset_progress_singleton():
|
||||
"""Destroy any cached progress-tracker singleton so the next call
|
||||
to get_progress_tracker() reads the current environment variable."""
|
||||
_progress_module.ProgressTracker._instance = None
|
||||
_progress_module._global_tracker = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RDF export correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMCPPackageExportGraphRDF(unittest.TestCase):
|
||||
"""handle_export_graph() must return a non-empty RDF string for every
|
||||
supported RDF format, not an error dict."""
|
||||
|
||||
def setUp(self):
|
||||
# Inject a known graph into the mcp/ session so handlers don't try to
|
||||
# build a full ContextGraph (which requires heavy ML dependencies).
|
||||
import mcp.session as _session
|
||||
self._orig_graph = _session._graph
|
||||
_session._graph = _make_graph()
|
||||
|
||||
def tearDown(self):
|
||||
import mcp.session as _session
|
||||
_session._graph = self._orig_graph
|
||||
|
||||
def test_turtle_returns_non_empty_string(self):
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "turtle"})
|
||||
self.assertNotIn("error", result, result)
|
||||
self.assertIsInstance(result["data"], str)
|
||||
self.assertGreater(len(result["data"]), 0)
|
||||
# Turtle output must carry prefix declarations
|
||||
self.assertIn("@prefix", result["data"])
|
||||
|
||||
def test_ttl_alias_returns_non_empty_string(self):
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "ttl"})
|
||||
self.assertNotIn("error", result, result)
|
||||
self.assertIsInstance(result["data"], str)
|
||||
self.assertGreater(len(result["data"]), 0)
|
||||
|
||||
def test_nt_returns_non_empty_string(self):
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "nt"})
|
||||
self.assertNotIn("error", result, result)
|
||||
self.assertIsInstance(result["data"], str)
|
||||
self.assertGreater(len(result["data"]), 0)
|
||||
|
||||
def test_xml_returns_non_empty_string(self):
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "xml"})
|
||||
self.assertNotIn("error", result, result)
|
||||
self.assertIsInstance(result["data"], str)
|
||||
self.assertGreater(len(result["data"]), 0)
|
||||
|
||||
def test_jsonld_returns_non_empty_string(self):
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "json-ld"})
|
||||
self.assertNotIn("error", result, result)
|
||||
self.assertIsInstance(result["data"], str)
|
||||
self.assertGreater(len(result["data"]), 0)
|
||||
|
||||
def test_all_rdf_formats_succeed(self):
|
||||
from mcp.tools.export import handle_export_graph
|
||||
for fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
|
||||
with self.subTest(fmt=fmt):
|
||||
result = handle_export_graph({"format": fmt})
|
||||
self.assertNotIn("error", result, f"format={fmt}: {result}")
|
||||
self.assertIsInstance(result["data"], str)
|
||||
self.assertGreater(len(result["data"]), 0)
|
||||
|
||||
def test_rdf_branch_does_not_raise_context_graph_attribute_error(self):
|
||||
"""The pre-fix code passed ContextGraph directly to export_to_rdf(),
|
||||
causing AttributeError: 'ContextGraph' object has no attribute 'get'.
|
||||
Verify that error does not appear in the result."""
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "turtle"})
|
||||
if "error" in result:
|
||||
self.assertNotIn("'ContextGraph' object has no attribute 'get'",
|
||||
result["error"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stdout protection — subprocess-based to avoid process-state cross-contamination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMCPPackageStdoutProtection(unittest.TestCase):
|
||||
"""The standalone mcp/ server must not write any progress bytes to stdout.
|
||||
stdout is the MCP JSON-RPC transport channel.
|
||||
|
||||
These tests use a subprocess to get a clean process state where
|
||||
SEMANTICA_DISABLE_PROGRESS has not yet been set, so we can verify that
|
||||
importing mcp and running an export produces no progress bytes on stdout.
|
||||
"""
|
||||
|
||||
def _run_in_subprocess(self, code: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
||||
"""Run a Python snippet in a clean subprocess with the repo on sys.path."""
|
||||
repo_root = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..")
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = repo_root
|
||||
# Start with a clean slate — no pre-set disable flag
|
||||
env.pop("SEMANTICA_DISABLE_PROGRESS", None)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=repo_root,
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_importing_mcp_sets_disable_progress(self):
|
||||
"""Importing the mcp package must set SEMANTICA_DISABLE_PROGRESS=1
|
||||
before any tool handler runs."""
|
||||
code = (
|
||||
"import os; "
|
||||
"import mcp; " # triggers mcp/__init__.py
|
||||
"print(os.environ.get('SEMANTICA_DISABLE_PROGRESS', 'NOT SET'))"
|
||||
)
|
||||
result = self._run_in_subprocess(code)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("1", result.stdout)
|
||||
|
||||
def test_rdf_export_writes_no_progress_to_stdout(self):
|
||||
"""An RDF export via handle_export_graph() must not write any Semantica
|
||||
progress bytes to stdout. The only stdout bytes should be the explicit
|
||||
print() call at the end of the snippet."""
|
||||
code = """
|
||||
import os, sys
|
||||
# Ensure clean state
|
||||
os.environ.pop("SEMANTICA_DISABLE_PROGRESS", None)
|
||||
|
||||
import mcp # sets SEMANTICA_DISABLE_PROGRESS=1
|
||||
import mcp.session as session
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
g = ContextGraph()
|
||||
g.add_node("n1", node_type="entity")
|
||||
g.add_node("n2", node_type="entity")
|
||||
g.add_edge("n1", "n2", "related_to")
|
||||
session._graph = g
|
||||
|
||||
# Intercept stdout writes to detect any progress output
|
||||
written = []
|
||||
_orig = sys.stdout.write
|
||||
def _capture(s):
|
||||
written.append(s)
|
||||
return _orig(s)
|
||||
sys.stdout.write = _capture
|
||||
|
||||
from mcp.tools.export import handle_export_graph
|
||||
result = handle_export_graph({"format": "turtle"})
|
||||
|
||||
sys.stdout.write = _orig
|
||||
|
||||
# Only our explicit output below should be in written
|
||||
# (the sentinel line is added after restoring stdout)
|
||||
progress_writes = [s for s in written]
|
||||
print("RESULT_OK:" + str("error" not in result))
|
||||
print("STDOUT_WRITES:" + str(len(progress_writes)))
|
||||
"""
|
||||
proc = self._run_in_subprocess(code)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
# Extract the printed lines
|
||||
lines = proc.stdout.strip().splitlines()
|
||||
result_ok_line = next((l for l in lines if l.startswith("RESULT_OK:")), None)
|
||||
writes_line = next((l for l in lines if l.startswith("STDOUT_WRITES:")), None)
|
||||
self.assertIsNotNone(result_ok_line, f"stdout: {proc.stdout!r}")
|
||||
self.assertIsNotNone(writes_line, f"stdout: {proc.stdout!r}")
|
||||
self.assertEqual(result_ok_line, "RESULT_OK:True",
|
||||
f"export returned error; stdout={proc.stdout!r}, stderr={proc.stderr!r}")
|
||||
n_writes = int(writes_line.split(":")[1])
|
||||
self.assertEqual(n_writes, 0,
|
||||
f"Expected 0 progress writes to stdout, got {n_writes}; "
|
||||
f"stdout={proc.stdout!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user