mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
ab93ec3e8f
commit
7b31304e1e
+242
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point: python -m mcp.server"""
|
||||
from mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user