Files
semantica/semantica/mcp_server/__init__.py
T
KaifAhmad1 84ccc7c0e3 fix(mcp): extract_relations tool crashes with missing entities arg
RelationExtractor.extract_relations(text, entities, ...) requires
entities, but the tool called it with only text, raising TypeError
on every invocation. Run NER first and pass the resulting entities
through, matching how the rest of the pipeline extracts relations.
2026-08-26 15:30:26 +05:30

853 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Semantica MCP Server
Exposes Semantica's knowledge graph, decision intelligence, semantic extraction,
reasoning, and analytics capabilities as an MCP (Model Context Protocol) server
over stdio — compatible with Claude Desktop, Windsurf, Cline, Continue, VS Code,
Roo Code, and any other MCP-aware tool.
Usage
-----
Configure in your tool's MCP settings:
Claude Desktop / Windsurf / Cline / Continue / VS Code:
{
"mcpServers": {
"semantica": {
"command": "semantica-mcp"
}
}
}
Or using python -m:
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
}
Run directly for testing:
semantica-mcp
# or
python -m semantica.mcp_server
Environment variables:
SEMANTICA_KG_PATH — path to a persisted graph to load on start (optional)
SEMANTICA_LOG_LEVEL — log level: DEBUG, INFO, WARNING (default: WARNING)
"""
from __future__ import annotations
import json
import logging
import os
import sys
from typing import Any
# `semantica.__version__` is the authoritative package version — it is kept in
# sync with pyproject.toml's static `version` field by the release process and
# is always present whenever this submodule is importable. Using it directly
# is simpler and more reliable than `importlib.metadata.version("semantica")`,
# which reads dist-info written at install time and can lag the source in
# editable installs (egg-info / dist-info is not regenerated on every version
# bump, so it can reflect a stale value).
from semantica import __version__ as _SEMANTICA_VERSION
# ── logging ────────────────────────────────────────────────────────────────
_log_level = getattr(logging, os.environ.get("SEMANTICA_LOG_LEVEL", "WARNING").upper(), logging.WARNING)
logging.basicConfig(stream=sys.stderr, level=_log_level,
format="%(asctime)s [semantica-mcp] %(levelname)s %(message)s")
log = logging.getLogger("semantica.mcp_server")
# ── lazy graph session ──────────────────────────────────────────────────────
_graph: Any = None
def _get_graph():
global _graph
if _graph is None:
from semantica.context import ContextGraph
_graph = ContextGraph(advanced_analytics=True)
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path and os.path.exists(kg_path):
try:
_graph.load_from_file(kg_path)
log.info("Loaded graph from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
return _graph
# ══════════════════════════════════════════════════════════════════════════════
# Tool implementations
# ══════════════════════════════════════════════════════════════════════════════
def _tool_extract_entities(args: dict) -> dict:
"""Extract named entities from text.
Optional ``model`` (spaCy pipeline, e.g. ``zh_core_web_sm`` for Chinese)
and ``language`` allow non-English NER; defaults to the Semantica English
pipeline when omitted. ``method`` defaults to ``ml`` (spaCy); other
options are ``huggingface``, ``llm``, ``pattern``.
"""
text = args.get("text", "")
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import NamedEntityRecognizer
init_kwargs = {}
for k in ("model", "language", "confidence_threshold"):
if args.get(k) is not None:
init_kwargs[k] = args[k]
method = args.get("method", "ml")
ner = NamedEntityRecognizer(methods=[method], **init_kwargs)
entities = ner.extract_entities(text)
return {
"entities": [
{"text": getattr(e, "text", ""),
"label": getattr(e, "label", ""),
"type": getattr(e, "label", None),
"start": getattr(e, "start_char", getattr(e, "start", None)),
"end": getattr(e, "end_char", getattr(e, "end", None)),
"confidence": getattr(e, "confidence", 1.0)}
for e in (entities or [])
]
}
def _tool_extract_relations(args: dict) -> dict:
"""Extract relations and triplets from text.
Optional ``model``/``language`` enable non-English extraction.
``method`` defaults to ``pattern``; ``dependency`` uses spaCy syntactic
parsing (requires a spaCy model, e.g. ``zh_core_web_sm``).
"""
text = args.get("text", "")
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import NamedEntityRecognizer, RelationExtractor, TripletExtractor
rel_kwargs = {}
ner_kwargs = {}
for k in ("model", "language"):
if args.get(k) is not None:
rel_kwargs[k] = args[k]
ner_kwargs[k] = args[k]
method = args.get("method", "pattern")
entities = NamedEntityRecognizer(methods=["ml"], **ner_kwargs).extract_entities(text) or []
relations = RelationExtractor(method=method, **rel_kwargs).extract_relations(text, entities)
triplets = TripletExtractor().extract_triplets(text)
return {
"relations": [
{"source": getattr(r, "source", None),
"type": getattr(r, "type", None),
"target": getattr(r, "target", None)}
for r in (relations or [])
],
"triplets": [
{"subject": getattr(t, "subject", None),
"predicate": getattr(t, "predicate", None),
"object": getattr(t, "object", None)}
for t in (triplets or [])
],
}
def _tool_record_decision(args: dict) -> dict:
"""Record a decision with full context into the graph."""
required = ["category", "scenario", "reasoning", "outcome", "confidence"]
for field in required:
if field not in args:
return {"error": f"missing required field: {field}"}
graph = _get_graph()
decision_id = graph.record_decision(
category=args["category"],
scenario=args["scenario"],
reasoning=args["reasoning"],
outcome=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"}
def _tool_query_decisions(args: dict) -> dict:
"""Query decisions by natural language or structured filters."""
query = args.get("query", "")
category = args.get("category")
limit = int(args.get("limit", 10))
graph = _get_graph()
try:
if query:
results = graph.find_similar_decisions(query, max_results=limit, min_similarity=0.05)
elif category:
nodes = graph.find_nodes(node_type="decision")
results = [n for n in nodes
if n.get("category") == category
or n.get("metadata", {}).get("category") == category][:limit]
else:
results = graph.find_nodes(node_type="decision")[:limit]
return {"decisions": results if isinstance(results, list) else list(results)}
except Exception as exc:
return {"error": str(exc), "decisions": []}
def _tool_find_precedents(args: dict) -> dict:
"""Find past decisions similar to a given scenario."""
scenario = args.get("scenario", "")
if not scenario:
return {"error": "scenario is required"}
max_results = int(args.get("max_results", 5))
graph = _get_graph()
try:
min_similarity = float(args.get("min_similarity", 0.05))
precedents = graph.find_similar_decisions(
scenario, max_results=max_results, min_similarity=min_similarity)
return {"precedents": precedents if isinstance(precedents, list) else list(precedents)}
except Exception as exc:
return {"error": str(exc), "precedents": []}
def _tool_get_causal_chain(args: dict) -> dict:
"""Get the causal chain for a decision."""
decision_id = args.get("decision_id", "")
if not decision_id:
return {"error": "decision_id is required"}
direction = args.get("direction", "downstream")
max_depth = int(args.get("max_depth", 5))
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)
return {"chain": chain if isinstance(chain, list) else list(chain)}
except Exception as exc:
return {"error": str(exc), "chain": []}
def _tool_add_entity(args: dict) -> dict:
"""Add a node/entity to the knowledge graph."""
node_id = args.get("id", "")
label = args.get("label", node_id)
node_type = args.get("type", "Entity")
if not node_id:
return {"error": "id is required"}
graph = _get_graph()
graph.add_node(node_id=node_id, label=label, node_type=node_type,
metadata=args.get("metadata", {}))
return {"status": "added", "id": node_id}
def _tool_add_relationship(args: dict) -> dict:
"""Add a relationship (edge) between two entities."""
source = args.get("source", "")
target = args.get("target", "")
rel_type = args.get("type", "RELATED_TO")
if not source or not target:
return {"error": "source and target are required"}
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}
def _tool_run_reasoning(args: dict) -> dict:
"""Run forward-chaining reasoning rules over a set of facts."""
facts = args.get("facts", [])
rules = args.get("rules", [])
if not facts or not rules:
return {"error": "facts and rules are required"}
from semantica.reasoning import Reasoner
reasoner = Reasoner()
for rule in rules:
reasoner.add_rule(rule)
derived = reasoner.infer_facts(facts)
return {"derived_facts": derived if isinstance(derived, list) else list(derived)}
def _tool_get_graph_analytics(args: dict) -> dict:
"""Compute graph analytics: centrality, community detection, metrics."""
graph = _get_graph()
try:
from semantica.kg import CentralityCalculator, CommunityDetector
centrality = CentralityCalculator().calculate_pagerank(graph)
communities = CommunityDetector().detect_communities(graph)
node_count = len(list(graph.find_nodes()))
edge_count = getattr(graph, "edge_count", lambda: 0)()
return {
"node_count": node_count,
"edge_count": edge_count,
"top_nodes_by_pagerank": sorted(
centrality.items() if hasattr(centrality, "items") else [],
key=lambda x: x[1], reverse=True
)[:10],
"community_count": len(communities) if isinstance(communities, (list, dict)) else 0,
}
except Exception as exc:
return {"error": str(exc)}
def _tool_export_graph(args: dict) -> dict:
"""Export the current knowledge graph to a serialised format."""
fmt = args.get("format", "json-ld")
graph = _get_graph()
try:
from semantica.export import RDFExporter, JSONExporter
if fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
result = RDFExporter().export_to_rdf(graph, format=fmt)
else:
result = JSONExporter().export(graph)
return {"format": fmt, "data": result}
except Exception as exc:
return {"error": str(exc)}
def _tool_get_graph_summary(args: dict) -> dict:
"""Return a high-level summary of the current graph."""
graph = _get_graph()
try:
node_count = len(list(graph.find_nodes()))
decisions = graph.find_nodes(node_type="decision")
return {
"node_count": node_count,
"decision_count": len(list(decisions)),
"graph_ready": True,
}
except Exception as exc:
return {"error": str(exc), "graph_ready": False}
def _tool_update_node(args: dict) -> dict:
"""Update properties of an existing node and persist to SEMANTICA_KG_PATH.
Common use: mark an action node's status (todo/doing/done) with an
optional note. The graph is mutated in-memory then saved back to the
file it was loaded from, so changes survive server restarts.
"""
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
properties = args.get("properties", {})
if not isinstance(properties, dict) or not properties:
return {"error": "properties (non-empty object) is required"}
graph = _get_graph()
try:
if not graph.find_node(node_id):
return {"error": f"node '{node_id}' not found"}
graph.add_node_attribute(node_id, properties)
# Persist back to disk so the change survives restarts
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
graph.save_to_file(kg_path)
persisted = True
else:
persisted = False
updated = graph.find_node(node_id)
return {
"status": "updated",
"node_id": node_id,
"properties": {k: (updated.get("metadata") or {}).get(k) for k in properties},
"persisted": persisted,
}
except Exception as exc:
return {"error": str(exc)}
def _tool_delete_node(args: dict) -> dict:
"""Archive a node (soft delete) and persist to SEMANTICA_KG_PATH.
The node is kept in the graph for history but marked status='archived'.
Use to retire an action you no longer actively track.
"""
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
graph = _get_graph()
try:
if not graph.find_node(node_id):
return {"error": f"node '{node_id}' not found"}
graph.add_node_attribute(node_id, {"status": "archived"})
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
graph.save_to_file(kg_path)
return {"status": "archived", "node_id": node_id, "persisted": bool(kg_path)}
except Exception as exc:
return {"error": str(exc)}
def _tool_query_graph(args: dict) -> dict:
"""Query the live knowledge graph: node detail, neighbours, or keyword search.
mode:
- "node" : get one node by id (needs node_id)
- "neighbors": traverse up to `depth` hops from node_id (default depth=1)
- "search" : keyword search over node id+content (needs query)
"""
graph = _get_graph()
mode = args.get("mode", "neighbors")
try:
if mode == "node":
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
node = graph.find_node(node_id)
return {"node": node}
if mode == "neighbors":
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
depth = int(args.get("depth", 1))
rel_types = args.get("relationship_types")
if isinstance(rel_types, str):
rel_types = [rel_types]
rel_set = set(rel_types) if rel_types else None
limit = args.get("limit")
limit = int(limit) if limit is not None else None
depth = min(max(depth, 1), 5)
# Out-edges (multi-hop) via get_neighbors
nb = graph.get_neighbors(
node_id, hops=depth, relationship_types=rel_types, limit=limit,
)
out = [
{"id": n.get("id"), "type": n.get("type"),
"content": n.get("content"),
"relationship": n.get("relationship"),
"direction": "out", "hop": n.get("hop", 1)}
for n in (nb or [])
]
# In-edges (1-hop): scan edges whose target == node_id.
# Deduplicate by source node so that multiple edges between the
# same pair of nodes (different edge types) produce one entry.
# Stop early once we have already collected `limit` inbound results
# (if a limit is set) to avoid scanning the full edge list.
inb = []
seen_inbound = set()
for e in graph.find_edges():
if e.get("target") != node_id:
continue
if rel_set is not None and e.get("type") not in rel_set:
continue
src_id = e.get("source")
if src_id in seen_inbound:
continue
seen_inbound.add(src_id)
src = graph.find_node(src_id) or {}
inb.append({"id": src_id, "type": src.get("type"),
"content": src.get("content"),
"relationship": e.get("type"),
"direction": "in", "hop": 1})
# Early-exit: we already have `limit` inbound results; the
# combined list will be truncated to `limit` anyway.
if limit is not None and len(inb) >= limit:
break
neighbors = out + inb
# Apply final limit. Use ``is not None`` so limit=0 (zero results)
# is honoured correctly; ``if limit:`` would treat 0 as falsy.
if limit is not None:
neighbors = neighbors[:limit]
return {"node_id": node_id, "depth": depth, "neighbors": neighbors}
if mode == "search":
q = (args.get("query") or "").lower()
if not q:
return {"error": "query is required"}
node_type = args.get("node_type")
limit = int(args.get("limit", 50))
nodes = graph.find_nodes(node_type=node_type) if node_type else graph.find_nodes()
hits = []
for n in nodes:
# Check limit BEFORE appending so limit=0 returns empty.
if len(hits) >= limit:
break
blob = f"{n.get('id','')} {n.get('content','')}".lower()
if q in blob:
hits.append({"id": n.get("id"), "type": n.get("type"),
"content": n.get("content")})
return {"query": q, "results": hits, "total": len(hits)}
return {"error": f"unknown mode '{mode}': use node|neighbors|search"}
except Exception as exc:
return {"error": str(exc)}
# ══════════════════════════════════════════════════════════════════════════════
# MCP protocol tables
# ══════════════════════════════════════════════════════════════════════════════
TOOLS = [
{
"name": "extract_entities",
"description": "Extract named entities (people, places, organisations, concepts) from text using Semantica NER.",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to extract entities from"},
"model": {"type": "string", "description": "spaCy model name, e.g. 'zh_core_web_sm' for Chinese, 'en_core_web_sm' for English. Defaults to English pipeline."},
"language": {"type": "string", "description": "Language code, e.g. 'zh', 'en'."},
"method": {"type": "string", "description": "Extraction method: 'ml' (spaCy, default), 'huggingface', 'llm', 'pattern'."},
"confidence_threshold": {"type": "number", "description": "Minimum confidence 0-1 (default 0.5)."}
},
"required": ["text"],
},
"_handler": _tool_extract_entities,
},
{
"name": "extract_relations",
"description": "Extract relations and (subject, predicate, object) triplets from text.",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to extract relations from"},
"model": {"type": "string", "description": "spaCy model name for dependency parsing, e.g. 'zh_core_web_sm'."},
"language": {"type": "string", "description": "Language code, e.g. 'zh'."},
"method": {"type": "string", "description": "Extraction method: 'pattern' (default), 'dependency', 'cooccurrence', 'huggingface', 'llm'."}
},
"required": ["text"],
},
"_handler": _tool_extract_relations,
},
{
"name": "record_decision",
"description": "Record a decision into the Semantica knowledge graph with full context, causal links, and metadata.",
"inputSchema": {
"type": "object",
"properties": {
"category": {"type": "string", "description": "Decision category, e.g. 'loan_approval'"},
"scenario": {"type": "string", "description": "Natural-language situation description"},
"reasoning": {"type": "string", "description": "Why this decision was made"},
"outcome": {"type": "string", "description": "Decision outcome, e.g. 'approved'"},
"confidence": {"type": "number", "description": "Confidence score 01"},
"decision_maker":{"type": "string", "description": "Who/what made the decision"},
"valid_from": {"type": "string", "description": "ISO date validity start (optional)"},
"valid_until": {"type": "string", "description": "ISO date validity end (optional)"},
},
"required": ["category", "scenario", "reasoning", "outcome", "confidence"],
},
"_handler": _tool_record_decision,
},
{
"name": "query_decisions",
"description": "Query recorded decisions by natural language, category, or get all recent decisions.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Natural language query (optional)"},
"category": {"type": "string", "description": "Filter by category (optional)"},
"limit": {"type": "integer", "description": "Max results (default 10)"},
},
},
"_handler": _tool_query_decisions,
},
{
"name": "find_precedents",
"description": "Find past decisions similar to a given scenario using hybrid similarity search.",
"inputSchema": {
"type": "object",
"properties": {
"scenario": {"type": "string", "description": "Scenario description to find precedents for"},
"max_results": {"type": "integer", "description": "Max results (default 5)"},
},
"required": ["scenario"],
},
"_handler": _tool_find_precedents,
},
{
"name": "get_causal_chain",
"description": "Trace the causal chain upstream or downstream from a decision.",
"inputSchema": {
"type": "object",
"properties": {
"decision_id": {"type": "string", "description": "Decision ID to trace"},
"direction": {"type": "string", "enum": ["upstream", "downstream"], "description": "Trace direction"},
"max_depth": {"type": "integer", "description": "Max chain depth (default 5)"},
},
"required": ["decision_id"],
},
"_handler": _tool_get_causal_chain,
},
{
"name": "add_entity",
"description": "Add a node/entity to the Semantica knowledge graph.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Unique node ID"},
"label": {"type": "string", "description": "Human-readable label"},
"type": {"type": "string", "description": "Node type, e.g. 'Person', 'Organisation'"},
"metadata": {"type": "object", "description": "Additional properties"},
},
"required": ["id"],
},
"_handler": _tool_add_entity,
},
{
"name": "add_relationship",
"description": "Add a directed relationship (edge) between two entities in the knowledge graph.",
"inputSchema": {
"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'"},
"metadata": {"type": "object", "description": "Additional edge properties"},
},
"required": ["source", "target"],
},
"_handler": _tool_add_relationship,
},
{
"name": "run_reasoning",
"description": "Run forward-chaining IF/THEN rules over a set of facts to derive new facts.",
"inputSchema": {
"type": "object",
"properties": {
"facts": {
"type": "array", "items": {"type": "string"},
"description": "List of 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 WorkerBee(?x)']",
},
},
"required": ["facts", "rules"],
},
"_handler": _tool_run_reasoning,
},
{
"name": "get_graph_analytics",
"description": "Compute PageRank centrality and community detection over the knowledge graph.",
"inputSchema": {"type": "object", "properties": {}},
"_handler": _tool_get_graph_analytics,
},
{
"name": "export_graph",
"description": "Export the current knowledge graph. Formats: turtle, ttl, nt, xml, json-ld, json.",
"inputSchema": {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json"],
"description": "Export format (default: json-ld)",
}
},
},
"_handler": _tool_export_graph,
},
{
"name": "get_graph_summary",
"description": "Return a high-level summary of the current knowledge graph: node count, decision count, status.",
"inputSchema": {"type": "object", "properties": {}},
"_handler": _tool_get_graph_summary,
},
{
"name": "query_graph",
"description": "Query the live knowledge graph: get a node, traverse its neighbours (up to 5 hops), or keyword-search nodes by id+content.",
"inputSchema": {
"type": "object",
"properties": {
"mode": {"type": "string", "description": "node | neighbors | search (default: neighbors)"},
"node_id": {"type": "string", "description": "Node id (required for node/neighbors mode)"},
"depth": {"type": "integer", "description": "Hop depth for neighbors (1-5, default 1)"},
"relationship_types": {"type": "array", "items": {"type": "string"}, "description": "Optional filter by edge type(s)"},
"query": {"type": "string", "description": "Keyword for search mode (matched against node id+content)"},
"node_type": {"type": "string", "description": "Optional node_type filter for search mode"},
"limit": {"type": "integer", "description": "Max results for neighbors/search"}
},
},
"_handler": _tool_query_graph,
},
{
"name": "update_node",
"description": "Update properties of an existing node (e.g. mark an action todo/doing/done with a note) and persist to SEMANTICA_KG_PATH.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string", "description": "Node id to update"},
"properties": {"type": "object", "description": "Property key-values to merge onto the node, e.g. {\"status\":\"done\",\"updated_at\":\"2026-08-13\",\"note\":\"...\"}"}
},
"required": ["node_id", "properties"],
},
"_handler": _tool_update_node,
},
{
"name": "delete_node",
"description": "Archive a node (soft delete: marks status='archived', keeps it for history) and persist to SEMANTICA_KG_PATH. Use to retire an action you no longer track.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string", "description": "Node id to delete"}
},
"required": ["node_id"],
},
"_handler": _tool_delete_node,
},
]
RESOURCES = [
{
"uri": "semantica://graph/summary",
"name": "Graph Summary",
"description": "High-level statistics about the current knowledge graph",
"mimeType": "application/json",
},
{
"uri": "semantica://decisions/list",
"name": "Decisions",
"description": "List of all recorded decisions in the graph",
"mimeType": "application/json",
},
{
"uri": "semantica://schema/info",
"name": "Schema Info",
"description": "Semantica server info and available capabilities",
"mimeType": "application/json",
},
]
def _read_resource(uri: str) -> dict:
if uri == "semantica://graph/summary":
return _tool_get_graph_summary({})
if uri == "semantica://decisions/list":
return _tool_query_decisions({"limit": 50})
if uri == "semantica://schema/info":
return {
"name": "Semantica",
"version": _SEMANTICA_VERSION,
"tools": [t["name"] for t in TOOLS],
"resources": [r["uri"] for r in RESOURCES],
}
return {"error": f"Unknown resource URI: {uri}"}
# ══════════════════════════════════════════════════════════════════════════════
# JSON-RPC / MCP protocol handler
# ══════════════════════════════════════════════════════════════════════════════
SERVER_INFO = {
"name": "semantica",
"version": _SEMANTICA_VERSION,
}
CAPABILITIES = {
"tools": {"listChanged": False},
"resources": {"listChanged": False, "subscribe": False},
}
def _handle(req: dict) -> dict | None:
"""Dispatch a single JSON-RPC request; return None for notifications."""
method = req.get("method", "")
params = req.get("params") or {}
req_id = req.get("id")
def ok(result):
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def err(code, message):
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
# Notifications (no id) — acknowledge silently
if req_id is None and method.startswith("notifications/"):
return None
if method == "initialize":
return ok({
"protocolVersion": "2024-11-05",
"capabilities": CAPABILITIES,
"serverInfo": SERVER_INFO,
})
if method == "notifications/initialized":
return None
if method == "ping":
return ok({})
if method == "tools/list":
tools_out = [
{"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
for t in TOOLS
]
return ok({"tools": tools_out})
if method == "tools/call":
name = params.get("name", "")
arguments = params.get("arguments") or {}
handler = next((t["_handler"] for t in TOOLS if t["name"] == name), None)
if handler is None:
return err(-32601, f"Unknown tool: {name}")
try:
result = handler(arguments)
text = json.dumps(result, ensure_ascii=False, indent=2)
return ok({"content": [{"type": "text", "text": text}]})
except Exception as exc:
log.exception("Tool %s raised", name)
return err(-32603, str(exc))
if method == "resources/list":
return ok({"resources": RESOURCES})
if method == "resources/read":
uri = params.get("uri", "")
data = _read_resource(uri)
text = json.dumps(data, ensure_ascii=False, indent=2)
return ok({"contents": [{"uri": uri, "mimeType": "application/json", "text": text}]})
if method == "prompts/list":
return ok({"prompts": []})
return err(-32601, f"Method not found: {method}")
# ══════════════════════════════════════════════════════════════════════════════
# stdio event loop
# ══════════════════════════════════════════════════════════════════════════════
def _run_stdio():
log.info("Semantica MCP server starting on stdio")
# Use binary stdin/stdout for reliable newline handling on Windows
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
while True:
try:
line = stdin.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError as exc:
resp = {"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": f"Parse error: {exc}"}}
stdout.write(json.dumps(resp).encode() + b"\n")
stdout.flush()
continue
resp = _handle(req)
if resp is not None:
stdout.write(json.dumps(resp, ensure_ascii=False).encode() + b"\n")
stdout.flush()
except EOFError:
break
except KeyboardInterrupt:
break
except Exception as exc:
log.exception("Unhandled error in MCP loop: %s", exc)
log.info("Semantica MCP server stopped")
def main():
_run_stdio()