Compare commits

...
Author SHA1 Message Date
BingEdward c449209168 fix(cli): dispatch mcp call through semantica_mcp.mcp.server (#1368)
Route `semantica mcp call` and `semantica mcp list-tools` through the
canonical `semantica_mcp.mcp.server` packaged module instead of the
nonexistent `MCPSession` import.

Previously, `semantica mcp call` imported `MCPSession` from
`semantica_mcp.mcp.session`, which never defined it, causing every
call to fail with "MCP module not available". Meanwhile,
`semantica mcp list-tools` inspected `semantica_mcp.mcp.tools.__all__`,
which evaluated to `["TOOL_DEFINITIONS"]` and printed a single tool
named "TOOL_DEFINITIONS".

Key changes:
- Implement `semantica_mcp.mcp.server.call_tool(name, arguments)` as the
  shared in-process entry point used by both the CLI and the JSON-RPC
  `tools/call` handler, ensuring both surfaces expose identical tools.
- Add `UnknownToolError` to distinguish missing tools (JSON-RPC -32601)
  from handler-level `KeyError` exceptions (JSON-RPC -32603).
- Update `semantica mcp list-tools` to source tool names directly from
  `TOOL_DEFINITIONS` in `semantica_mcp.mcp.tools`.
- Validate `--args` payloads and reject non-object JSON inputs (arrays,
  primitives, null) with a clean `ClickException`.
- Update `tests/test_mcp_stdio_roundtrip.py` to spawn `semantica_mcp.mcp`
  instead of the pre-relocation `mcp` module, restoring clean stdio
  framing tests.

Fixes #1355
2026-09-08 02:40:53 +05:00
Sameer KadamandSameer Kadam 7be1582786 fix: correct vector store installation extras (#1529)
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-08 01:42:20 +05:00
6 changed files with 145 additions and 35 deletions
+4 -4
View File
@@ -160,7 +160,7 @@ No installation or API key required. FAISS requires `pip install faiss-cpu`.
<Tab title="Pinecone">
```bash
pip install "semantica[pinecone]"
pip install "semantica[vectorstore-pinecone]"
```
```python
@@ -178,7 +178,7 @@ store = VectorStore(
<Tab title="Weaviate">
```bash
pip install "semantica[weaviate]"
pip install "semantica[vectorstore-weaviate]"
```
```python
@@ -194,7 +194,7 @@ store = VectorStore(
<Tab title="Qdrant">
```bash
pip install "semantica[qdrant]"
pip install "semantica[vectorstore-qdrant]"
```
```python
@@ -210,7 +210,7 @@ store = VectorStore(
<Tab title="PgVector">
```bash
pip install "semantica[pgvector]"
pip install "semantica[vectorstore-pgvector]"
```
```python
+12 -14
View File
@@ -4764,15 +4764,10 @@ def mcp_list_tools(cli_ctx: CLIContext, local_json: bool) -> None:
cli_ctx = _require_ctx(cli_ctx)
def _action() -> None:
try:
from semantica_mcp.mcp.tools import __all__ as tools
except ImportError:
tools = [
"extract_entities", "extract_relations", "build_graph",
"query_graph", "get_graph_analytics", "run_reasoning",
"record_decision", "get_decisions", "export_graph",
"validate_shacl", "get_provenance", "embed_and_search",
]
# Same catalog the server exposes via tools/list, so `list-tools`
# and `mcp start` can't drift (issue #1355).
from semantica_mcp.mcp.tools import TOOL_DEFINITIONS
tools = [t["name"] for t in TOOL_DEFINITIONS]
if _is_json(cli_ctx, local_json):
_jecho({"tools": list(tools)})
else:
@@ -4805,12 +4800,15 @@ def mcp_call(cli_ctx: CLIContext, tool_name: str, args: str, local_json: bool) -
tool_args = json.loads(args)
except json.JSONDecodeError as exc:
raise click.ClickException(f"Invalid JSON in --args: {exc}") from exc
if not isinstance(tool_args, dict):
raise click.ClickException("--args must be a JSON object")
# Dispatch through the same server `mcp start` spawns; its session
# module never defined MCPSession (issue #1355).
from semantica_mcp.mcp.server import UnknownToolError, call_tool
try:
from semantica_mcp.mcp.session import MCPSession
session = MCPSession(config=cli_ctx.config.to_dict())
result = session.call_tool(tool_name, **tool_args)
except ImportError as exc:
raise click.ClickException(f"MCP module not available: {exc}") from exc
result = call_tool(tool_name, tool_args)
except UnknownToolError as exc:
raise click.ClickException(str(exc)) from exc
if _is_json(cli_ctx, local_json):
_jecho(result if isinstance(result, (dict, list)) else {"result": str(result)})
else:
+24 -5
View File
@@ -51,6 +51,27 @@ _INTERNAL_ERROR = -32603
_TOOL_INDEX: dict[str, dict] = {t["name"]: t for t in TOOL_DEFINITIONS}
class UnknownToolError(Exception):
"""Raised by :func:`call_tool` when the tool name is not in the catalog.
A dedicated type (rather than ``KeyError``) so callers can distinguish
a bad tool name from a ``KeyError`` raised inside a handler indexing a
required argument (e.g. ``args["category"]``).
"""
def call_tool(name: str, arguments: dict) -> dict:
"""Invoke a tool in-process by name and return its raw result dict.
Shared by the JSON-RPC ``tools/call`` handler and ``semantica mcp call``
(issue #1355), so both expose exactly the same tool set.
"""
tool = _TOOL_INDEX.get(name)
if tool is None:
raise UnknownToolError(f"Unknown tool: {name}")
return tool["_handler"](arguments)
# ---------------------------------------------------------------------------
# Request handlers
# ---------------------------------------------------------------------------
@@ -85,12 +106,10 @@ def _handle_tools_call(req_id: Any, params: dict) -> dict:
name = params.get("name", "")
args = params.get("arguments", {}) or {}
tool = _TOOL_INDEX.get(name)
if tool is None:
return _err(req_id, _METHOD_NOT_FOUND, f"Unknown tool: {name}")
try:
result = tool["_handler"](args)
result = call_tool(name, args)
except UnknownToolError as exc:
return _err(req_id, _METHOD_NOT_FOUND, str(exc))
except Exception as exc:
log.exception("Tool %s raised an exception", name)
# The exception's class name (e.g. "ValidationError", "TimeoutError")
+39 -10
View File
@@ -2090,12 +2090,16 @@ class TestMCP:
# Table renders correctly — at minimum the column header is present
assert "Tool" in result.output or "tool" in result.output.lower()
def test_list_tools_with_mock_shows_known_tools(self, runner, monkeypatch):
fake_tools = _fake_module(__all__=["extract_entities", "query_graph"])
monkeypatch.setitem(__import__("sys").modules, "semantica_mcp.mcp.tools", fake_tools)
def test_list_tools_reads_server_catalog(self, runner, monkeypatch):
"""list-tools must read TOOL_DEFINITIONS (what the server serves via
tools/list), not the module's ``__all__`` (issue #1355)."""
import semantica_mcp.mcp.tools as tools_mod
fake = [{"name": "fake_tool_from_catalog", "description": "", "inputSchema": {},
"_handler": lambda a: {}}]
monkeypatch.setattr(tools_mod, "TOOL_DEFINITIONS", fake)
result = runner.invoke(cli_module.main, ["mcp", "list-tools"])
_ok(result)
assert "extract_entities" in result.output
assert "fake_tool_from_catalog" in result.output
def test_list_tools_json(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "list-tools", "--json"])
@@ -2138,14 +2142,39 @@ class TestMCP:
err = json.loads(result.stderr)
assert err["error"].startswith("Invalid JSON in --args")
def test_call_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("mcp") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["mcp", "call", "extract_entities"])
def test_call_dispatches_through_packaged_server(self, runner):
"""Regression for issue #1355: ``mcp call`` dispatches in-process through
``semantica_mcp.mcp.server`` (the server ``mcp start`` spawns) instead
of importing the nonexistent ``MCPSession``."""
result = runner.invoke(
cli_module.main, ["--json", "mcp", "call", "extract_entities"]
)
_ok(result)
# Empty args short-circuit before heavy imports; reaching the
# handler's own validation proves the dispatch path works.
assert "text is required" in result.output
def test_call_unknown_tool_fails_cleanly(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "call", "no_such_tool"])
assert result.exit_code != 0
assert "Traceback" not in result.output
assert "Unknown tool" in result.output
def test_call_non_object_args_rejected(self, runner):
result = runner.invoke(
cli_module.main, ["mcp", "call", "extract_entities", "--args", "[1, 2]"]
)
assert result.exit_code != 0
assert "Traceback" not in result.output
assert "--args must be a JSON object" in result.output
def test_list_tools_json_matches_server_catalog(self, runner):
"""The CLI catalog and the MCP server catalog must be the same list."""
from semantica_mcp.mcp.tools import TOOL_DEFINITIONS
result = runner.invoke(cli_module.main, ["mcp", "list-tools", "--json"])
_ok(result)
data = _json_output(result)
assert data["tools"] == [t["name"] for t in TOOL_DEFINITIONS]
# ─── services group (backward-compat wrapper) ─────────────────────────────────
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the shared in-process tool entry point (issue #1355).
``semantica_mcp.mcp.server.call_tool`` is the dispatch used by both the
JSON-RPC ``tools/call`` handler and the ``semantica mcp call`` CLI command,
so the two surfaces cannot expose different tool sets.
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from semantica_mcp.mcp import server
from semantica_mcp.mcp.server import UnknownToolError, _handle_tools_call, call_tool
class TestCallTool(unittest.TestCase):
def test_known_tool_dispatches_to_handler(self):
# Empty args hit extract_entities' own validation before any heavy
# imports, which is enough to prove dispatch reached the handler.
result = call_tool("extract_entities", {})
self.assertEqual(result["error"], "text is required")
def test_unknown_tool_raises_unknown_tool_error(self):
with self.assertRaises(UnknownToolError):
call_tool("no_such_tool", {})
def test_unknown_tool_error_is_not_a_key_error(self):
"""A handler's own KeyError (missing required arg) must remain
distinguishable from an unknown tool name."""
self.assertFalse(issubclass(UnknownToolError, KeyError))
class TestToolsCallDispatch(unittest.TestCase):
@staticmethod
def _tools_call(name, arguments):
return _handle_tools_call(1, {"name": name, "arguments": arguments})
def test_unknown_tool_returns_method_not_found(self):
response = self._tools_call("no_such_tool", {})
self.assertEqual(response["error"]["code"], -32601)
self.assertEqual(response["error"]["message"], "Unknown tool: no_such_tool")
def test_handler_key_error_is_internal_error_not_unknown_tool(self):
def _boom(args):
raise KeyError("category")
fake = {"name": "boom", "description": "", "inputSchema": {}, "_handler": _boom}
with patch.dict(server._TOOL_INDEX, {"boom": fake}):
response = self._tools_call("boom", {})
self.assertEqual(response["error"]["code"], -32603)
def test_known_tool_returns_result_content(self):
response = self._tools_call("extract_entities", {})
self.assertIn("content", response["result"])
self.assertTrue(response["result"]["isError"])
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -91,7 +91,7 @@ _INIT_REQUEST = _jsonrpc("initialize", 1, {
# ---------------------------------------------------------------------------
class TestMCPStdioFramingContract(unittest.TestCase):
"""Run 'python -m mcp' exactly as an MCP client would, over a real pipe.
"""Run 'python -m semantica_mcp.mcp' exactly as an MCP client would, over a real pipe.
Each test sends a complete JSON-RPC session through stdin and asserts that
every byte on stdout is valid JSON catching the exact failure mode from
@@ -102,7 +102,7 @@ class TestMCPStdioFramingContract(unittest.TestCase):
def _run(self, *requests: bytes) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "mcp"],
[sys.executable, "-m", "semantica_mcp.mcp"],
input=b"".join(requests),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,