Merge pull request #1151 from Arasz/fix/mcp-export-graph

fix(mcp): export_graph failed on every format — convert kg dict, disable progress
This commit is contained in:
Mohd Kaif
2026-08-25 16:25:19 +05:30
committed by GitHub
5 changed files with 362 additions and 5 deletions
+11
View File
@@ -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
View File
@@ -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}"}
+23 -4
View File
@@ -62,6 +62,13 @@ logging.basicConfig(stream=sys.stderr, level=_log_level,
format="%(asctime)s [semantica-mcp] %(levelname)s %(message)s")
log = logging.getLogger("semantica.mcp_server")
# MCP stdio framing IS stdout: a progress bar or other console renderer writing
# to stdout would interleave with the JSON-RPC stream and hang every client
# (observed 2026-08-20: export_graph over MCP timed out at 300s while the same
# call returned in <1s directly). Force the progress trackers off for this
# process — stdout is not a console here.
os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
# ── lazy graph session ──────────────────────────────────────────────────────
_graph: Any = None
@@ -260,16 +267,28 @@ def _tool_get_graph_analytics(args: dict) -> dict:
return {"error": str(exc)}
_EXPORT_GRAPH_FORMATS = ("turtle", "ttl", "nt", "xml", "json-ld", "json")
def _tool_export_graph(args: dict) -> dict:
"""Export the current knowledge graph to a serialised format."""
fmt = args.get("format", "json-ld")
if fmt not in _EXPORT_GRAPH_FORMATS:
return {
"error": f"Unsupported format '{fmt}'. Supported: {', '.join(_EXPORT_GRAPH_FORMATS)}"
}
graph = _get_graph()
try:
from semantica.export import RDFExporter, JSONExporter
from semantica.export import RDFExporter
# The exporters consume the canonical kg dict, not the ContextGraph
# object (regression: the old code passed the object straight through,
# so every branch failed — JSONExporter.export() with no file_path on
# the json branch, AttributeError on the RDF branches).
kg = graph.to_kg_dict()
if fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
result = RDFExporter().export_to_rdf(graph, format=fmt)
result = RDFExporter().export_to_rdf(kg, format=fmt)
else:
result = JSONExporter().export(graph)
result = json.dumps(kg, indent=2, ensure_ascii=False)
return {"format": fmt, "data": result}
except Exception as exc:
return {"error": str(exc)}
@@ -441,7 +460,7 @@ TOOLS = [
"properties": {
"format": {
"type": "string",
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json"],
"enum": list(_EXPORT_GRAPH_FORMATS),
"description": "Export format (default: json-ld)",
}
},
+232
View File
@@ -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()
+90
View File
@@ -0,0 +1,90 @@
"""Regression tests for the MCP export_graph tool (issue: all branches broken).
The MCP server's export_graph tool failed on every format in 0.6.5/0.6.6:
- json: JSONExporter().export(graph) called without the required file_path
argument -> TypeError, surfaced as {"error": ...}
- RDF: RDFExporter().export_to_rdf(graph, ...) received the ContextGraph
object instead of the canonical kg dict -> AttributeError
- all: the RDF path printed a rich progress bar to stdout, corrupting the
stdio JSON-RPC framing and hanging the client (observed: 300s
timeout over MCP, <1s directly).
The fix: convert the graph with ContextGraph.to_kg_dict() before handing it to
the exporters, serialize json to a string, and force SEMANTICA_DISABLE_PROGRESS
for the server process (stdout is the protocol channel, not a console).
"""
import json
import os
import unittest
from semantica import mcp_server
from semantica.context import ContextGraph
def _graph_with_content() -> ContextGraph:
graph = ContextGraph(advanced_analytics=True)
graph.add_node("n1", node_type="entity", properties={"text": "hello"})
graph.add_node("n2", node_type="entity", properties={"text": "world"})
graph.add_edge("n1", "n2", "related_to")
return graph
class TestExportGraphTool(unittest.TestCase):
def setUp(self):
self._old_graph = mcp_server._graph
mcp_server._graph = _graph_with_content()
def tearDown(self):
mcp_server._graph = self._old_graph
def test_json_branch_returns_string_data_not_error(self):
result = mcp_server._tool_export_graph({"format": "json"})
self.assertNotIn("error", result)
self.assertEqual(result["format"], "json")
payload = json.loads(result["data"])
self.assertEqual(len(payload["entities"]), 2)
self.assertEqual(len(payload["relationships"]), 1)
def test_jsonld_branch_returns_string_data_not_error(self):
result = mcp_server._tool_export_graph({"format": "json-ld"})
self.assertNotIn("error", result)
self.assertEqual(result["format"], "json-ld")
self.assertIsInstance(result["data"], str)
self.assertGreater(len(result["data"]), 0)
def test_turtle_branch_returns_string_data_not_error(self):
result = mcp_server._tool_export_graph({"format": "turtle"})
self.assertNotIn("error", result)
self.assertIsInstance(result["data"], str)
self.assertIn("@prefix", result["data"])
def test_all_rdf_formats_succeed(self):
for fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
with self.subTest(fmt=fmt):
result = mcp_server._tool_export_graph({"format": fmt})
self.assertNotIn("error", result, fmt)
self.assertIsInstance(result["data"], str)
def test_progress_is_disabled_for_the_server_process(self):
self.assertEqual(os.environ.get("SEMANTICA_DISABLE_PROGRESS"), "1")
def test_unsupported_format_returns_error_not_mislabeled_json(self):
"""A format outside the declared enum (typo, unsupported value, or a
client that skips schema validation) must error, not silently return
JSON data mislabeled with the requested format string."""
result = mcp_server._tool_export_graph({"format": "yaml"})
self.assertIn("error", result)
self.assertIn("yaml", result["error"])
def test_export_graph_schema_enum_matches_handled_formats(self):
"""The tool's declared inputSchema enum must not drift from the set
of formats the handler actually accepts."""
tool = next(t for t in mcp_server.TOOLS if t["name"] == "export_graph")
schema_enum = set(tool["inputSchema"]["properties"]["format"]["enum"])
self.assertEqual(schema_enum, set(mcp_server._EXPORT_GRAPH_FORMATS))
if __name__ == "__main__":
unittest.main()