feat(mcp): add modular MCP server package at repo root

Adds a fully self-contained `mcp/` package that exposes Semantica as a
Model Context Protocol server over stdio (JSON-RPC 2.0).

17 tools across 5 domains:
- Extraction: extract_entities, extract_relations, extract_all
- Decision intelligence: record_decision, query_decisions, find_precedents,
  get_causal_chain, analyze_decision_impact
- Knowledge graph: add_entity, add_relationship, search_graph,
  get_graph_summary, get_graph_analytics
- Reasoning: run_reasoning, abductive_reasoning
- Export & provenance: export_graph (JSON/CSV/GraphML/Parquet/RDF), get_provenance

4 resources: semantica://graph/summary, semantica://decisions/list,
semantica://schema/info, semantica://ontology/schema

Package layout:
  mcp/__init__.py + __main__.py  — entry points (python -m mcp)
  mcp/server.py                  — SemanticaMCPServer + stdio event loop
  mcp/session.py                 — lazy ContextGraph singleton
  mcp/schemas.py                 — JSON Schema for all 17 tool inputs
  mcp/tools/{extraction,decisions,graph,reasoning,export}.py
  mcp/resources/registry.py      — URI → handler map
  mcp/README.md                  — per-tool setup (Claude Code, Cursor, Windsurf,
                                   Cline, Continue, VS Code, Amazon Q)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
KaifAhmad1
2026-04-13 17:38:23 +05:30
co-authored by Claude Sonnet 4.6
parent ab93ec3e8f
commit 7b31304e1e
14 changed files with 1763 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
# Semantica MCP Server
A fully modular [Model Context Protocol](https://modelcontextprotocol.io/) server for the Semantica knowledge graph.
Connects Claude Code, Cursor, Windsurf, Cline, Continue, VS Code (GitHub Copilot), and any other MCP-compatible AI tool directly to your Semantica graph.
---
## Quick start
```bash
# From the repo root
pip install -e ".[mcp]"
# Test the server (type a JSON-RPC request, press Enter)
python -m mcp
```
Or point your AI tool at it (see per-tool configs below).
---
## Transport
**stdio** — the server reads newline-delimited JSON-RPC 2.0 from `stdin` and writes responses to `stdout`.
Log/debug output goes to `stderr` only.
```
python -m mcp [--debug]
```
---
## Tools (17 total)
### Extraction
| Tool | Description |
|---|---|
| `extract_entities` | Named entity recognition (NER) — people, places, orgs, concepts |
| `extract_relations` | Relation extraction + (subject, predicate, object) triplets |
| `extract_all` | Full pipeline: NER + coreference + relations + events + triplets |
### Decision Intelligence
| Tool | Description |
|---|---|
| `record_decision` | Record a decision with context, confidence, causal links |
| `query_decisions` | Query decisions by natural language or structured filters |
| `find_precedents` | Find past decisions similar to a scenario (hybrid similarity) |
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
| `analyze_decision_impact` | Analyse downstream influence of a decision |
### Knowledge Graph
| Tool | Description |
|---|---|
| `add_entity` | Add a node/entity to the graph |
| `add_relationship` | Add a directed edge between two entities |
| `search_graph` | Search nodes by label or ID substring |
| `get_graph_summary` | Node/edge counts, decision count, type breakdown |
| `get_graph_analytics` | PageRank, betweenness, degree centrality, community detection |
### Reasoning
| Tool | Description |
|---|---|
| `run_reasoning` | Forward-chaining IF/THEN rules over facts |
| `abductive_reasoning` | Generate plausible hypotheses for observations |
### Export & Provenance
| Tool | Description |
|---|---|
| `export_graph` | Export graph to JSON, CSV, GraphML, Parquet, Turtle, N-Triples, RDF/XML, JSON-LD |
| `get_provenance` | Audit history and source lineage for a node |
---
## Resources (4 total)
| URI | Description |
|---|---|
| `semantica://graph/summary` | Live node/edge counts and type breakdown |
| `semantica://decisions/list` | Most recent 50 decisions |
| `semantica://schema/info` | Schema version, node/edge types, tool names |
| `semantica://ontology/schema` | Full ontology schema |
---
## Per-tool configuration
### Claude Code (`~/.claude/settings.json`)
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
Or use the plugin bundle:
```bash
claude mcp add semantica python -m mcp --cwd /path/to/semantica
```
---
### Cursor (`~/.cursor/mcp.json`)
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
### Windsurf (`~/.codeium/windsurf/mcp_config.json`)
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
### Cline (VS Code extension settings)
In your VS Code `settings.json`:
```json
{
"cline.mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
### Continue (`~/.continue/config.json`)
```json
{
"mcpServers": [
{
"name": "semantica",
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
]
}
```
---
### VS Code (GitHub Copilot) — `.vscode/mcp.json`
```json
{
"servers": {
"semantica": {
"type": "stdio",
"command": "python",
"args": ["-m", "mcp"],
"cwd": "${workspaceFolder}"
}
}
}
```
---
### Amazon Q Developer
Add to your Q Developer MCP config:
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `SEMANTICA_KG_PATH` | *(in-memory)* | Path to persist/load the graph (JSON file) |
---
## Package structure
```
mcp/
├── __init__.py # Package entry, re-exports SemanticaMCPServer + main
├── __main__.py # python -m mcp entry point
├── server.py # SemanticaMCPServer class + stdio event loop
├── session.py # Lazy ContextGraph singleton (get_graph / reset_graph)
├── schemas.py # JSON Schema definitions for all tool inputs
├── tools/
│ ├── __init__.py # Assembles TOOL_DEFINITIONS list
│ ├── extraction.py # NER, relation extraction, full pipeline
│ ├── decisions.py # Record, query, precedents, causal chain, impact
│ ├── graph.py # Add entity/relationship, search, summary, analytics
│ ├── reasoning.py # Forward-chaining rules, abductive hypotheses
│ └── export.py # Graph export (multi-format) + provenance
└── resources/
├── __init__.py # Re-exports RESOURCE_DEFINITIONS + handle_resource_read
└── registry.py # URI → handler map for the 4 semantica:// resources
```
+27
View File
@@ -0,0 +1,27 @@
"""
Semantica MCP Server Package
A full Model Context Protocol (MCP) server for Semantica — exposes knowledge graph
construction, semantic extraction, decision intelligence, reasoning, analytics,
and export capabilities as MCP tools and resources.
Run the server:
python -m mcp.server # from repo root
python -m semantica.mcp_server # alias inside installed package
Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp.server"],
"cwd": "/path/to/semantica"
}
}
}
"""
from .server import SemanticaMCPServer, main
__all__ = ["SemanticaMCPServer", "main"]
__version__ = "0.4.0"
+5
View File
@@ -0,0 +1,5 @@
"""Entry point: python -m mcp.server"""
from mcp.server import main
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
"""
MCP resource registry — static and dynamic resources exposed via resources/list
and resources/read.
"""
from .registry import RESOURCE_DEFINITIONS, handle_resource_read
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
+153
View File
@@ -0,0 +1,153 @@
"""
Resource handlers for Semantica MCP resources.
Each resource maps a semantica:// URI to a callable that returns
{"uri": ..., "mimeType": ..., "text": ...}.
"""
from __future__ import annotations
import json
import logging
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.resources")
def _read_graph_summary(uri: str) -> dict:
try:
graph = get_graph()
all_nodes = list(graph.find_nodes())
node_types: dict[str, int] = {}
for n in all_nodes:
t = str(n.get("type", "Unknown"))
node_types[t] = node_types.get(t, 0) + 1
edge_count = 0
if hasattr(graph, "edge_count"):
try:
edge_count = graph.edge_count()
except Exception:
pass
data = {
"node_count": len(all_nodes),
"edge_count": edge_count,
"node_types": node_types,
}
except Exception as exc:
data = {"error": str(exc)}
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2)}
def _read_decisions_list(uri: str) -> dict:
try:
graph = get_graph()
nodes = list(graph.find_nodes(node_type="decision"))
decisions = [
{
"id": n.get("id"),
"category": n.get("category"),
"outcome": n.get("outcome"),
"scenario": str(n.get("scenario", ""))[:120],
}
for n in nodes[:50]
]
data = {"decisions": decisions, "count": len(decisions)}
except Exception as exc:
data = {"error": str(exc), "decisions": []}
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2)}
def _read_schema_info(uri: str) -> dict:
info = {
"version": "0.4.0",
"node_types": [
"Entity", "decision", "Decision", "Event", "Concept",
"Person", "Organisation", "Location",
],
"edge_types": [
"RELATED_TO", "CAUSED_BY", "LEADS_TO", "PART_OF",
"INSTANCE_OF", "SIMILAR_TO",
],
"tools": [
"extract_entities", "extract_relations", "extract_all",
"record_decision", "query_decisions", "find_precedents",
"get_causal_chain", "analyze_decision_impact",
"add_entity", "add_relationship", "search_graph",
"get_graph_summary", "get_graph_analytics",
"run_reasoning", "abductive_reasoning",
"export_graph", "get_provenance",
],
}
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(info, indent=2)}
def _read_ontology_schema(uri: str) -> dict:
try:
graph = get_graph()
try:
from semantica.ontology import OntologyManager
mgr = OntologyManager(graph_store=graph)
schema = mgr.get_schema()
text = json.dumps(schema, indent=2) if isinstance(schema, dict) else str(schema)
except (ImportError, AttributeError):
text = json.dumps({"message": "Ontology manager not available"}, indent=2)
except Exception as exc:
text = json.dumps({"error": str(exc)}, indent=2)
return {"uri": uri, "mimeType": "application/json", "text": text}
# Map URI → handler
_HANDLERS: dict[str, object] = {
"semantica://graph/summary": _read_graph_summary,
"semantica://decisions/list": _read_decisions_list,
"semantica://schema/info": _read_schema_info,
"semantica://ontology/schema": _read_ontology_schema,
}
RESOURCE_DEFINITIONS = [
{
"uri": "semantica://graph/summary",
"name": "Graph Summary",
"description": "High-level summary of the current knowledge graph: node/edge counts and type breakdown.",
"mimeType": "application/json",
},
{
"uri": "semantica://decisions/list",
"name": "Decision List",
"description": "Most recent decisions recorded in the knowledge graph (up to 50).",
"mimeType": "application/json",
},
{
"uri": "semantica://schema/info",
"name": "Schema Info",
"description": "Semantica schema version, supported node/edge types, and available tool names.",
"mimeType": "application/json",
},
{
"uri": "semantica://ontology/schema",
"name": "Ontology Schema",
"description": "Full ontology schema from the OntologyManager (concept hierarchy and constraints).",
"mimeType": "application/json",
},
]
def handle_resource_read(uri: str) -> dict:
"""Dispatch a resources/read request to the appropriate handler."""
handler = _HANDLERS.get(uri)
if handler is None:
return {
"uri": uri,
"mimeType": "application/json",
"text": json.dumps({"error": f"Unknown resource URI: {uri}"}),
}
try:
return handler(uri) # type: ignore[call-arg]
except Exception as exc:
log.exception("resource_read failed for %s", uri)
return {
"uri": uri,
"mimeType": "application/json",
"text": json.dumps({"error": str(exc)}),
}
+292
View File
@@ -0,0 +1,292 @@
"""
Input schema definitions for all MCP tools.
Each entry is the JSON Schema object placed in the tool's ``inputSchema``
field. Keeping them here avoids duplication across tool modules.
"""
EXTRACTION_TEXT = {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Input text to process",
}
},
"required": ["text"],
}
EXTRACT_ENTITIES = EXTRACTION_TEXT
EXTRACT_RELATIONS = EXTRACTION_TEXT
EXTRACT_ALL = {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to process"},
"include_events": {
"type": "boolean",
"description": "Also extract events (default: true)",
},
"include_triplets": {
"type": "boolean",
"description": "Also extract (subject, predicate, object) triplets (default: true)",
},
},
"required": ["text"],
}
RECORD_DECISION = {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Decision category, e.g. 'loan_approval', 'deployment'",
},
"scenario": {
"type": "string",
"description": "Natural-language description of the situation",
},
"reasoning": {
"type": "string",
"description": "Explanation of why this decision was made",
},
"outcome": {
"type": "string",
"description": "Decision result, e.g. 'approved', 'rejected', 'deferred'",
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score between 0 and 1",
},
"decision_maker": {
"type": "string",
"description": "Who or what made the decision (default: mcp_client)",
},
"valid_from": {
"type": "string",
"description": "ISO 8601 validity start date (optional)",
},
"valid_until": {
"type": "string",
"description": "ISO 8601 validity end date (optional)",
},
},
"required": ["category", "scenario", "reasoning", "outcome", "confidence"],
}
QUERY_DECISIONS = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural language query (optional)",
},
"category": {
"type": "string",
"description": "Filter by exact category (optional)",
},
"outcome": {
"type": "string",
"description": "Filter by outcome value (optional)",
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Maximum number of results (default: 10)",
},
},
}
FIND_PRECEDENTS = {
"type": "object",
"properties": {
"scenario": {
"type": "string",
"description": "Scenario description to find similar past decisions for",
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "Maximum number of precedents to return (default: 5)",
},
},
"required": ["scenario"],
}
GET_CAUSAL_CHAIN = {
"type": "object",
"properties": {
"decision_id": {
"type": "string",
"description": "ID of the decision to trace",
},
"direction": {
"type": "string",
"enum": ["upstream", "downstream", "both"],
"description": "Trace direction (default: downstream)",
},
"max_depth": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "Maximum chain depth (default: 5)",
},
},
"required": ["decision_id"],
}
ANALYZE_DECISION_IMPACT = {
"type": "object",
"properties": {
"decision_id": {
"type": "string",
"description": "ID of the decision to analyse",
},
},
"required": ["decision_id"],
}
ADD_ENTITY = {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique node identifier",
},
"label": {
"type": "string",
"description": "Human-readable label (defaults to id)",
},
"type": {
"type": "string",
"description": "Node type, e.g. 'Person', 'Organisation', 'Concept'",
},
"metadata": {
"type": "object",
"description": "Additional key-value properties",
},
},
"required": ["id"],
}
ADD_RELATIONSHIP = {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "Source node ID",
},
"target": {
"type": "string",
"description": "Target node ID",
},
"type": {
"type": "string",
"description": "Relationship type, e.g. 'WORKS_AT', 'CAUSED_BY'",
},
"metadata": {
"type": "object",
"description": "Additional edge properties",
},
},
"required": ["source", "target"],
}
SEARCH_GRAPH = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term or phrase",
},
"node_type": {
"type": "string",
"description": "Filter by node type (optional)",
},
"limit": {
"type": "integer",
"description": "Max results (default: 20)",
},
},
"required": ["query"],
}
RUN_REASONING = {
"type": "object",
"properties": {
"facts": {
"type": "array",
"items": {"type": "string"},
"description": "Fact strings, e.g. ['Person(John)', 'Employee(John)']",
},
"rules": {
"type": "array",
"items": {"type": "string"},
"description": "IF/THEN rule strings, e.g. ['IF Employee(?x) THEN Worker(?x)']",
},
},
"required": ["facts", "rules"],
}
ABDUCTIVE_REASONING = {
"type": "object",
"properties": {
"observations": {
"type": "array",
"items": {"type": "string"},
"description": "Observed facts to explain",
},
"max_hypotheses": {
"type": "integer",
"description": "Max hypotheses to generate (default: 5)",
},
},
"required": ["observations"],
}
EXPORT_GRAPH = {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json", "csv"],
"description": "Export format (default: json-ld)",
},
},
}
GET_PROVENANCE = {
"type": "object",
"properties": {
"entity_id": {
"type": "string",
"description": "Entity or node ID to get provenance for",
},
},
"required": ["entity_id"],
}
GET_ANALYTICS = {
"type": "object",
"properties": {
"metrics": {
"type": "array",
"items": {
"type": "string",
"enum": ["pagerank", "betweenness", "communities", "degree", "all"],
},
"description": "Analytics to compute (default: ['all'])",
},
"top_n": {
"type": "integer",
"description": "Top N nodes to return per metric (default: 10)",
},
},
}
EMPTY = {"type": "object", "properties": {}}
+224
View File
@@ -0,0 +1,224 @@
"""
Semantica MCP Server — JSON-RPC 2.0 over stdio.
Implements the Model Context Protocol so any MCP-compatible AI tool
(Claude Code, Cursor, Windsurf, Cline, Continue, VS Code Copilot, etc.)
can interact with the Semantica knowledge graph.
Run:
python -m mcp # via __main__.py
python -m mcp.server # direct
"""
from __future__ import annotations
import json
import logging
import sys
from typing import Any
from mcp.resources import RESOURCE_DEFINITIONS, handle_resource_read
from mcp.tools import TOOL_DEFINITIONS
log = logging.getLogger("semantica.mcp.server")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ok(request_id: Any, result: Any) -> dict:
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def _err(request_id: Any, code: int, message: str, data: Any = None) -> dict:
error: dict = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": request_id, "error": error}
# JSON-RPC error codes
_PARSE_ERROR = -32700
_INVALID_REQUEST = -32600
_METHOD_NOT_FOUND = -32601
_INVALID_PARAMS = -32602
_INTERNAL_ERROR = -32603
# ---------------------------------------------------------------------------
# Tool dispatch index
# ---------------------------------------------------------------------------
_TOOL_INDEX: dict[str, dict] = {t["name"]: t for t in TOOL_DEFINITIONS}
# ---------------------------------------------------------------------------
# Request handlers
# ---------------------------------------------------------------------------
def _handle_initialize(req_id: Any, params: dict) -> dict:
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
"resources": {},
},
"serverInfo": {
"name": "semantica-mcp",
"version": "0.4.0",
},
})
def _handle_tools_list(req_id: Any, _params: dict) -> dict:
tools = [
{
"name": t["name"],
"description": t["description"],
"inputSchema": t["inputSchema"],
}
for t in TOOL_DEFINITIONS
]
return _ok(req_id, {"tools": tools})
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)
except Exception as exc:
log.exception("Tool %s raised an exception", name)
return _err(req_id, _INTERNAL_ERROR, str(exc))
# MCP spec: content must be a list of content items
return _ok(req_id, {
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
"isError": "error" in result,
})
def _handle_resources_list(req_id: Any, _params: dict) -> dict:
return _ok(req_id, {"resources": RESOURCE_DEFINITIONS})
def _handle_resources_read(req_id: Any, params: dict) -> dict:
uri = params.get("uri", "").strip()
if not uri:
return _err(req_id, _INVALID_PARAMS, "uri is required")
resource = handle_resource_read(uri)
return _ok(req_id, {
"contents": [
{
"uri": resource["uri"],
"mimeType": resource.get("mimeType", "application/json"),
"text": resource.get("text", ""),
}
]
})
def _handle_ping(req_id: Any, _params: dict) -> dict:
return _ok(req_id, {})
# ---------------------------------------------------------------------------
# Dispatch table
# ---------------------------------------------------------------------------
_DISPATCH = {
"initialize": _handle_initialize,
"tools/list": _handle_tools_list,
"tools/call": _handle_tools_call,
"resources/list": _handle_resources_list,
"resources/read": _handle_resources_read,
"ping": _handle_ping,
}
# ---------------------------------------------------------------------------
# Main server class
# ---------------------------------------------------------------------------
class SemanticaMCPServer:
"""Semantica MCP server — reads JSON-RPC requests from stdin, writes to stdout."""
def __init__(self, *, debug: bool = False) -> None:
level = logging.DEBUG if debug else logging.WARNING
logging.basicConfig(stream=sys.stderr, level=level,
format="%(name)s %(levelname)s %(message)s")
# ------------------------------------------------------------------
def dispatch(self, request: dict) -> dict | None:
"""Process one JSON-RPC request and return a response dict (or None for notifications)."""
req_id = request.get("id") # None for notifications
method = request.get("method", "")
params = request.get("params") or {}
handler = _DISPATCH.get(method)
if handler is None:
if req_id is None:
return None # Notification — ignore unknown methods silently
return _err(req_id, _METHOD_NOT_FOUND, f"Method not found: {method}")
try:
return handler(req_id, params)
except Exception as exc:
log.exception("Unhandled error in method %s", method)
if req_id is None:
return None
return _err(req_id, _INTERNAL_ERROR, str(exc))
# ------------------------------------------------------------------
def run(self) -> None:
"""Start the stdio event loop."""
log.info("Semantica MCP server starting (stdio)")
for raw_line in sys.stdin:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
request = json.loads(raw_line)
except json.JSONDecodeError as exc:
response = _err(None, _PARSE_ERROR, f"Parse error: {exc}")
_write(response)
continue
if isinstance(request, list):
# Batch request
responses = []
for req in request:
resp = self.dispatch(req)
if resp is not None:
responses.append(resp)
if responses:
_write(responses)
else:
resp = self.dispatch(request)
if resp is not None:
_write(resp)
def _write(obj: Any) -> None:
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="Semantica MCP Server")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
SemanticaMCPServer(debug=args.debug).run()
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
"""
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
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
if _graph is None:
from semantica.context import ContextGraph
_graph = ContextGraph(advanced_analytics=True)
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
if kg_path and os.path.exists(kg_path):
try:
_graph.load(kg_path)
log.info("Graph loaded from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
return _graph
def reset_graph() -> None:
"""Reset the singleton (mainly useful in tests)."""
global _graph
_graph = None
+22
View File
@@ -0,0 +1,22 @@
"""
MCP tool registry — imports all tool handlers and assembles TOOL_DEFINITIONS.
Each module under mcp/tools/ registers its handlers here.
"""
from .decisions import DECISION_TOOLS
from .export import EXPORT_TOOLS
from .extraction import EXTRACTION_TOOLS
from .graph import GRAPH_TOOLS
from .reasoning import REASONING_TOOLS
# Ordered list — exposed to the MCP client via tools/list
TOOL_DEFINITIONS = (
EXTRACTION_TOOLS
+ DECISION_TOOLS
+ GRAPH_TOOLS
+ REASONING_TOOLS
+ EXPORT_TOOLS
)
__all__ = ["TOOL_DEFINITIONS"]
+166
View File
@@ -0,0 +1,166 @@
"""
Decision intelligence tools — record, query, precedents, causal chain, impact.
"""
from __future__ import annotations
import logging
from mcp.schemas import (
ANALYZE_DECISION_IMPACT,
FIND_PRECEDENTS,
GET_CAUSAL_CHAIN,
QUERY_DECISIONS,
RECORD_DECISION,
)
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.decisions")
def handle_record_decision(args: dict) -> dict:
"""Record a decision with full context into the knowledge graph."""
required = ["category", "scenario", "reasoning", "outcome", "confidence"]
missing = [f for f in required if f not in args]
if missing:
return {"error": f"Missing required fields: {', '.join(missing)}"}
try:
graph = get_graph()
decision_id = graph.record_decision(
category=str(args["category"]),
scenario=str(args["scenario"]),
reasoning=str(args["reasoning"]),
outcome=str(args["outcome"]),
confidence=float(args["confidence"]),
entities=args.get("entities", []),
decision_maker=args.get("decision_maker", "mcp_client"),
valid_from=args.get("valid_from"),
valid_until=args.get("valid_until"),
)
return {
"decision_id": decision_id,
"status": "recorded",
"category": args["category"],
"outcome": args["outcome"],
}
except Exception as exc:
log.exception("record_decision failed")
return {"error": str(exc)}
def handle_query_decisions(args: dict) -> dict:
"""Query recorded decisions by natural language or structured filters."""
query = args.get("query", "").strip()
category = args.get("category", "").strip()
outcome_filter = args.get("outcome", "").strip()
limit = int(args.get("limit", 10))
try:
graph = get_graph()
if query:
results = graph.find_similar_decisions(query, max_results=limit)
decisions = results if isinstance(results, list) else list(results)
else:
nodes = graph.find_nodes(node_type="decision")
decisions = list(nodes)[:limit * 5] # over-fetch for filtering
if category:
decisions = [d for d in decisions if d.get("category") == category]
if outcome_filter:
decisions = [d for d in decisions if d.get("outcome") == outcome_filter]
decisions = decisions[:limit]
return {"decisions": decisions, "count": len(decisions)}
except Exception as exc:
log.exception("query_decisions failed")
return {"error": str(exc), "decisions": []}
def handle_find_precedents(args: dict) -> dict:
"""Find past decisions similar to a given scenario using hybrid similarity search."""
scenario = args.get("scenario", "").strip()
if not scenario:
return {"error": "scenario is required", "precedents": []}
max_results = int(args.get("max_results", 5))
try:
graph = get_graph()
precedents = graph.find_similar_decisions(scenario, max_results=max_results)
results = precedents if isinstance(precedents, list) else list(precedents)
return {"precedents": results, "count": len(results)}
except Exception as exc:
log.exception("find_precedents failed")
return {"error": str(exc), "precedents": []}
def handle_get_causal_chain(args: dict) -> dict:
"""Trace the upstream or downstream causal chain from a decision."""
decision_id = args.get("decision_id", "").strip()
if not decision_id:
return {"error": "decision_id is required", "chain": []}
direction = args.get("direction", "downstream")
max_depth = int(args.get("max_depth", 5))
try:
graph = get_graph()
try:
from semantica.context.causal_analyzer import CausalChainAnalyzer
analyzer = CausalChainAnalyzer(graph_store=graph)
chain = analyzer.get_causal_chain(
decision_id, direction=direction, max_depth=max_depth
)
except (ImportError, AttributeError):
chain = graph.get_causal_chain(decision_id) if hasattr(graph, "get_causal_chain") else []
result = chain if isinstance(chain, list) else list(chain)
return {"chain": result, "count": len(result), "direction": direction}
except Exception as exc:
log.exception("get_causal_chain failed")
return {"error": str(exc), "chain": []}
def handle_analyze_decision_impact(args: dict) -> dict:
"""Analyse the downstream impact of a decision on the graph."""
decision_id = args.get("decision_id", "").strip()
if not decision_id:
return {"error": "decision_id is required"}
try:
graph = get_graph()
if hasattr(graph, "analyze_decision_impact"):
impact = graph.analyze_decision_impact(decision_id)
elif hasattr(graph, "analyze_decision_influence"):
impact = graph.analyze_decision_influence(decision_id)
else:
impact = {"message": "impact analysis not available on this graph instance"}
return {"decision_id": decision_id, "impact": impact}
except Exception as exc:
log.exception("analyze_decision_impact failed")
return {"error": str(exc)}
DECISION_TOOLS = [
{
"name": "record_decision",
"description": "Record a decision with full context, causal links, and metadata into the Semantica knowledge graph.",
"inputSchema": RECORD_DECISION,
"_handler": handle_record_decision,
},
{
"name": "query_decisions",
"description": "Query recorded decisions by natural language, category, or outcome filter.",
"inputSchema": QUERY_DECISIONS,
"_handler": handle_query_decisions,
},
{
"name": "find_precedents",
"description": "Find past decisions similar to a given scenario using hybrid similarity search.",
"inputSchema": FIND_PRECEDENTS,
"_handler": handle_find_precedents,
},
{
"name": "get_causal_chain",
"description": "Trace the causal chain upstream or downstream from a recorded decision.",
"inputSchema": GET_CAUSAL_CHAIN,
"_handler": handle_get_causal_chain,
},
{
"name": "analyze_decision_impact",
"description": "Analyse the downstream impact and influence of a decision across the knowledge graph.",
"inputSchema": ANALYZE_DECISION_IMPACT,
"_handler": handle_analyze_decision_impact,
},
]
+150
View File
@@ -0,0 +1,150 @@
"""
Export tools — graph export (JSON/RDF/CSV/GraphML/Parquet) and provenance.
"""
from __future__ import annotations
import logging
from mcp.schemas import EXPORT_GRAPH, GET_PROVENANCE
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.export")
_FORMAT_ALIASES: dict[str, str] = {
"ttl": "turtle",
"turtle": "turtle",
"nt": "nt",
"xml": "xml",
"json-ld": "json-ld",
"jsonld": "json-ld",
}
def handle_export_graph(args: dict) -> dict:
"""Export the knowledge graph to a structured format."""
fmt = str(args.get("format", "json")).lower().strip()
include_metadata = bool(args.get("include_metadata", True))
try:
graph = get_graph()
if fmt == "json":
nodes = list(graph.find_nodes())
edges: list = []
if hasattr(graph, "find_edges"):
try:
edges = list(graph.find_edges())
except Exception:
pass
payload: dict = {"nodes": nodes, "edges": edges}
if include_metadata:
payload["meta"] = {
"node_count": len(nodes),
"edge_count": len(edges),
"format": "json",
}
return {"format": "json", "data": payload}
if fmt in ("csv",):
nodes = list(graph.find_nodes())
rows = []
for n in nodes:
rows.append(",".join([
str(n.get("id", "")),
str(n.get("label", "")),
str(n.get("type", "")),
]))
header = "id,label,type"
return {"format": "csv", "data": header + "\n" + "\n".join(rows)}
if fmt in ("graphml",):
try:
from semantica.export import GraphMLExporter
exporter = GraphMLExporter()
data = exporter.export(graph)
return {"format": "graphml", "data": data}
except Exception as exc:
return {"error": f"GraphML export failed: {exc}"}
if fmt in ("parquet",):
try:
from semantica.export import ParquetExporter
exporter = ParquetExporter()
data = exporter.export(graph)
return {"format": "parquet", "data": str(data)}
except Exception as exc:
return {"error": f"Parquet export failed: {exc}"}
# RDF formats
rdf_fmt = _FORMAT_ALIASES.get(fmt)
if rdf_fmt:
try:
from semantica.export import RDFExporter
rdf_str = RDFExporter().export_to_rdf(graph, format=rdf_fmt)
return {"format": rdf_fmt, "data": rdf_str}
except Exception as exc:
return {"error": f"RDF export failed: {exc}"}
return {"error": f"Unsupported format '{fmt}'. Supported: json, csv, graphml, parquet, turtle, nt, xml, json-ld"}
except Exception as exc:
log.exception("export_graph failed")
return {"error": str(exc)}
def handle_get_provenance(args: dict) -> dict:
"""Retrieve the provenance / audit history for a node."""
node_id = args.get("node_id", "").strip()
if not node_id:
return {"error": "node_id is required", "provenance": []}
include_metadata = bool(args.get("include_metadata", True))
try:
graph = get_graph()
# Try ProvenanceTracker first
try:
from semantica.kg import ProvenanceTracker
tracker = ProvenanceTracker(graph_store=graph)
records = tracker.get_provenance(node_id)
result = records if isinstance(records, list) else list(records)
except (ImportError, AttributeError):
# Fallback: look for provenance on the node itself
nodes = list(graph.find_nodes())
matched = [n for n in nodes if n.get("id") == node_id]
if matched:
node = matched[0]
prov = node.get("provenance") or node.get("source") or node.get("metadata", {})
result = [prov] if prov else []
else:
result = []
payload: dict = {"node_id": node_id, "provenance": result, "count": len(result)}
if include_metadata and result:
payload["sources"] = list({
str(r.get("source", r.get("origin", "")))
for r in result
if isinstance(r, dict)
})
return payload
except Exception as exc:
log.exception("get_provenance failed")
return {"error": str(exc), "provenance": []}
EXPORT_TOOLS = [
{
"name": "export_graph",
"description": (
"Export the Semantica knowledge graph to JSON, CSV, GraphML, Parquet, "
"Turtle (RDF), N-Triples, RDF/XML, or JSON-LD."
),
"inputSchema": EXPORT_GRAPH,
"_handler": handle_export_graph,
},
{
"name": "get_provenance",
"description": "Retrieve the provenance and audit history for a specific node in the knowledge graph.",
"inputSchema": GET_PROVENANCE,
"_handler": handle_get_provenance,
},
]
+167
View File
@@ -0,0 +1,167 @@
"""
Extraction tools — NER, relation extraction, event detection, triplets.
"""
from __future__ import annotations
import logging
from typing import Any
from mcp.schemas import EXTRACT_ALL, EXTRACT_ENTITIES, EXTRACT_RELATIONS
log = logging.getLogger("semantica.mcp.tools.extraction")
def _clear_cache() -> None:
try:
from semantica.semantic_extract.cache import _result_cache
_result_cache.clear()
except Exception:
pass
def handle_extract_entities(args: dict) -> dict:
"""Extract named entities from text using Semantica NER."""
text = args.get("text", "").strip()
if not text:
return {"error": "text is required", "entities": []}
_clear_cache()
try:
from semantica.semantic_extract import NamedEntityRecognizer
entities = NamedEntityRecognizer().extract(text) or []
return {
"entities": [
{
"label": getattr(e, "label", str(e)),
"type": getattr(e, "type", None),
"start": getattr(e, "start", None),
"end": getattr(e, "end", None),
"confidence": getattr(e, "confidence", None),
}
for e in entities
],
"count": len(entities),
}
except Exception as exc:
log.exception("extract_entities failed")
return {"error": str(exc), "entities": []}
def handle_extract_relations(args: dict) -> dict:
"""Extract relations and triplets from text."""
text = args.get("text", "").strip()
if not text:
return {"error": "text is required", "relations": [], "triplets": []}
_clear_cache()
try:
from semantica.semantic_extract import RelationExtractor, TripletExtractor
relations = RelationExtractor().extract(text) or []
triplets = TripletExtractor().extract(text) or []
return {
"relations": [
{
"source": getattr(r, "source", None),
"type": getattr(r, "type", None),
"target": getattr(r, "target", None),
"confidence": getattr(r, "confidence", None),
}
for r in relations
],
"triplets": [
{
"subject": getattr(t, "subject", None),
"predicate": getattr(t, "predicate", None),
"object": getattr(t, "object", None),
}
for t in triplets
],
"relation_count": len(relations),
"triplet_count": len(triplets),
}
except Exception as exc:
log.exception("extract_relations failed")
return {"error": str(exc), "relations": [], "triplets": []}
def handle_extract_all(args: dict) -> dict:
"""Run the full extraction pipeline: NER + relations + events + triplets."""
text = args.get("text", "").strip()
if not text:
return {"error": "text is required"}
include_events = args.get("include_events", True)
include_triplets = args.get("include_triplets", True)
_clear_cache()
result: dict[str, Any] = {}
try:
from semantica.semantic_extract import (
CoreferenceResolver,
EventDetector,
NamedEntityRecognizer,
RelationExtractor,
TripletExtractor,
)
entities = NamedEntityRecognizer().extract(text) or []
result["entities"] = [
{"label": getattr(e, "label", str(e)), "type": getattr(e, "type", None)}
for e in entities
]
resolved = CoreferenceResolver().resolve(text)
relations = RelationExtractor().extract(resolved) or []
result["relations"] = [
{"source": getattr(r, "source", None),
"type": getattr(r, "type", None),
"target": getattr(r, "target", None)}
for r in relations
]
if include_events:
events = EventDetector().extract(text) or []
result["events"] = [
{"type": getattr(ev, "type", None),
"trigger": getattr(ev, "trigger", str(ev))}
for ev in events
]
if include_triplets:
triplets = TripletExtractor().extract(resolved) or []
result["triplets"] = [
{"subject": getattr(t, "subject", None),
"predicate": getattr(t, "predicate", None),
"object": getattr(t, "object", None)}
for t in triplets
]
result["summary"] = {
"entities": len(result.get("entities", [])),
"relations": len(result.get("relations", [])),
"events": len(result.get("events", [])),
"triplets": len(result.get("triplets", [])),
}
return result
except Exception as exc:
log.exception("extract_all failed")
return {"error": str(exc)}
EXTRACTION_TOOLS = [
{
"name": "extract_entities",
"description": "Extract named entities (people, places, organisations, concepts) from text.",
"inputSchema": EXTRACT_ENTITIES,
"_handler": handle_extract_entities,
},
{
"name": "extract_relations",
"description": "Extract relations and (subject, predicate, object) triplets from text.",
"inputSchema": EXTRACT_RELATIONS,
"_handler": handle_extract_relations,
},
{
"name": "extract_all",
"description": "Run the full Semantica extraction pipeline: NER, coreference resolution, relation extraction, event detection, and triplet generation.",
"inputSchema": EXTRACT_ALL,
"_handler": handle_extract_all,
},
]
+187
View File
@@ -0,0 +1,187 @@
"""
Graph tools — add entities/relationships, search, analytics, summary.
"""
from __future__ import annotations
import logging
from mcp.schemas import ADD_ENTITY, ADD_RELATIONSHIP, EMPTY, GET_ANALYTICS, SEARCH_GRAPH
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.graph")
def handle_add_entity(args: dict) -> dict:
"""Add a node/entity to the Semantica knowledge graph."""
node_id = args.get("id", "").strip()
if not node_id:
return {"error": "id is required"}
try:
graph = get_graph()
graph.add_node(
node_id=node_id,
label=args.get("label", node_id),
node_type=args.get("type", "Entity"),
metadata=args.get("metadata", {}),
)
return {"status": "added", "id": node_id, "type": args.get("type", "Entity")}
except Exception as exc:
log.exception("add_entity failed")
return {"error": str(exc)}
def handle_add_relationship(args: dict) -> dict:
"""Add a directed relationship (edge) between two entities."""
source = args.get("source", "").strip()
target = args.get("target", "").strip()
if not source or not target:
return {"error": "source and target are required"}
rel_type = args.get("type", "RELATED_TO")
try:
graph = get_graph()
graph.add_edge(
source_id=source,
target_id=target,
edge_type=rel_type,
metadata=args.get("metadata", {}),
)
return {"status": "added", "source": source, "target": target, "type": rel_type}
except Exception as exc:
log.exception("add_relationship failed")
return {"error": str(exc)}
def handle_search_graph(args: dict) -> dict:
"""Search nodes in the knowledge graph by label or metadata."""
query = args.get("query", "").strip()
if not query:
return {"error": "query is required", "results": []}
node_type = args.get("node_type", "").strip() or None
limit = int(args.get("limit", 20))
try:
graph = get_graph()
if node_type:
nodes = list(graph.find_nodes(node_type=node_type))
else:
nodes = list(graph.find_nodes())
q = query.lower()
matched = [
n for n in nodes
if q in str(n.get("label", "")).lower()
or q in str(n.get("id", "")).lower()
][:limit]
return {"results": matched, "count": len(matched), "query": query}
except Exception as exc:
log.exception("search_graph failed")
return {"error": str(exc), "results": []}
def handle_get_graph_summary(args: dict) -> dict: # noqa: ARG001
"""Return a high-level summary of the current knowledge graph."""
try:
graph = get_graph()
all_nodes = list(graph.find_nodes())
decisions = [n for n in all_nodes if n.get("type") in ("decision", "Decision")]
node_types: dict[str, int] = {}
for n in all_nodes:
t = str(n.get("type", "Unknown"))
node_types[t] = node_types.get(t, 0) + 1
edge_count = 0
if hasattr(graph, "edge_count"):
try:
edge_count = graph.edge_count()
except Exception:
pass
return {
"node_count": len(all_nodes),
"edge_count": edge_count,
"decision_count": len(decisions),
"node_types": node_types,
"graph_ready": True,
}
except Exception as exc:
log.exception("get_graph_summary failed")
return {"error": str(exc), "graph_ready": False}
def handle_get_graph_analytics(args: dict) -> dict:
"""Compute centrality, community detection, and other graph metrics."""
requested = args.get("metrics", ["all"])
top_n = int(args.get("top_n", 10))
compute_all = "all" in requested
result: dict = {}
try:
graph = get_graph()
from semantica.kg import CentralityCalculator, CommunityDetector
if compute_all or "pagerank" in requested:
try:
pr = CentralityCalculator().calculate_pagerank(graph)
items = pr.items() if hasattr(pr, "items") else []
result["pagerank"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
except Exception as exc:
result["pagerank_error"] = str(exc)
if compute_all or "betweenness" in requested:
try:
bc = CentralityCalculator().calculate_betweenness_centrality(graph)
items = bc.items() if hasattr(bc, "items") else []
result["betweenness"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
except Exception as exc:
result["betweenness_error"] = str(exc)
if compute_all or "communities" in requested:
try:
comms = CommunityDetector().detect_communities(graph)
result["community_count"] = len(comms) if isinstance(comms, (list, dict)) else 0
result["communities"] = comms if isinstance(comms, list) else []
except Exception as exc:
result["communities_error"] = str(exc)
if compute_all or "degree" in requested:
try:
deg = CentralityCalculator().calculate_degree_centrality(graph)
items = deg.items() if hasattr(deg, "items") else []
result["degree"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
except Exception as exc:
result["degree_error"] = str(exc)
return result
except Exception as exc:
log.exception("get_graph_analytics failed")
return {"error": str(exc)}
GRAPH_TOOLS = [
{
"name": "add_entity",
"description": "Add a node or entity (person, place, concept, organisation) to the knowledge graph.",
"inputSchema": ADD_ENTITY,
"_handler": handle_add_entity,
},
{
"name": "add_relationship",
"description": "Add a directed relationship (edge) between two entities in the knowledge graph.",
"inputSchema": ADD_RELATIONSHIP,
"_handler": handle_add_relationship,
},
{
"name": "search_graph",
"description": "Search nodes in the knowledge graph by label or ID substring.",
"inputSchema": SEARCH_GRAPH,
"_handler": handle_search_graph,
},
{
"name": "get_graph_summary",
"description": "Return a high-level summary of the knowledge graph: node count, edge count, decision count, node type breakdown.",
"inputSchema": EMPTY,
"_handler": handle_get_graph_summary,
},
{
"name": "get_graph_analytics",
"description": "Compute PageRank centrality, betweenness centrality, degree centrality, and community detection over the knowledge graph.",
"inputSchema": GET_ANALYTICS,
"_handler": handle_get_graph_analytics,
},
]
+73
View File
@@ -0,0 +1,73 @@
"""
Reasoning tools — forward chaining, abductive reasoning.
"""
from __future__ import annotations
import logging
from mcp.schemas import ABDUCTIVE_REASONING, RUN_REASONING
log = logging.getLogger("semantica.mcp.tools.reasoning")
def handle_run_reasoning(args: dict) -> dict:
"""Run forward-chaining IF/THEN rules over facts to derive new knowledge."""
facts = args.get("facts", [])
rules = args.get("rules", [])
if not facts:
return {"error": "facts list is required", "derived_facts": []}
if not rules:
return {"error": "rules list is required", "derived_facts": []}
try:
from semantica.reasoning import Reasoner
reasoner = Reasoner()
for rule in rules:
reasoner.add_rule(str(rule))
derived = reasoner.infer_facts(facts)
result = derived if isinstance(derived, list) else list(derived)
return {
"derived_facts": result,
"count": len(result),
"input_facts": len(facts),
"rules_applied": len(rules),
}
except Exception as exc:
log.exception("run_reasoning failed")
return {"error": str(exc), "derived_facts": []}
def handle_abductive_reasoning(args: dict) -> dict:
"""Generate plausible hypotheses that explain a set of observations."""
observations = args.get("observations", [])
if not observations:
return {"error": "observations list is required", "hypotheses": []}
max_hypotheses = int(args.get("max_hypotheses", 5))
try:
from semantica.reasoning import AbductiveReasoner
reasoner = AbductiveReasoner()
hypotheses = reasoner.generate_hypotheses(observations)
result = hypotheses if isinstance(hypotheses, list) else list(hypotheses)
return {
"hypotheses": result[:max_hypotheses],
"count": min(len(result), max_hypotheses),
}
except Exception as exc:
log.exception("abductive_reasoning failed")
return {"error": str(exc), "hypotheses": []}
REASONING_TOOLS = [
{
"name": "run_reasoning",
"description": "Run forward-chaining IF/THEN rules over a set of facts to derive new facts. E.g. facts=['Person(John)'], rules=['IF Person(?x) THEN Mortal(?x)'] → derives 'Mortal(John)'.",
"inputSchema": RUN_REASONING,
"_handler": handle_run_reasoning,
},
{
"name": "abductive_reasoning",
"description": "Generate plausible hypotheses that best explain a set of observed facts.",
"inputSchema": ABDUCTIVE_REASONING,
"_handler": handle_abductive_reasoning,
},
]