فهرست منبع

initial MCP bridge scaffolding

Lukas Goldschmidt 1 ماه پیش
والد
کامیت
b0f0f5604d
8فایلهای تغییر یافته به همراه237 افزوده شده و 0 حذف شده
  1. 16 0
      .gitignore
  2. 22 0
      killserver.sh
  3. 4 0
      requirements.txt
  4. 8 0
      restart.sh
  5. 26 0
      run.sh
  6. 1 0
      server.pid
  7. 22 0
      test.sh
  8. 138 0
      virtuoso_mcp.py

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+# Bytecode
+__pycache__/
+*.py[cod]
+*.so
+
+# Environment
+.env
+.env*
+.venv/
+venv/
+
+# Logs
+*.log
+
+# VSCode
+.vscode/

+ 22 - 0
killserver.sh

@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+PID_FILE="server.pid"
+
+if [[ ! -f "$PID_FILE" ]]; then
+  echo "No PID file found; server not running?"
+  exit 1
+fi
+
+PID=$(cat "$PID_FILE")
+if kill -0 "$PID" >/dev/null 2>&1; then
+  kill "$PID"
+  sleep 1
+  echo "Server (PID $PID) terminated."
+else
+  echo "PID $PID is not running."
+fi
+rm -f "$PID_FILE"

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+fastapi>=0.115
+uvicorn[standard]>=0.23
+pydantic>=2.6
+requests>=2.31

+ 8 - 0
restart.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+./killserver.sh >/dev/null 2>&1 || true
+./run.sh

+ 26 - 0
run.sh

@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+LOG_DIR="logs"
+mkdir -p "$LOG_DIR"
+PID_FILE="server.pid"
+LOG_FILE="$LOG_DIR/server.log"
+
+if [[ -f "$PID_FILE" ]]; then
+  PID=$(cat "$PID_FILE")
+  if kill -0 "$PID" >/dev/null 2>&1; then
+    echo "Server already running (PID $PID)."
+    exit 1
+  else
+    echo "Stale PID file, removing."
+    rm -f "$PID_FILE"
+  fi
+fi
+
+nohup python3 -m uvicorn virtuoso_mcp:app --host 0.0.0.0 --port 8501 >"$LOG_FILE" 2>&1 &
+PID=$!
+echo "$PID" >"$PID_FILE"
+echo "Server started (PID $PID). Logs: $LOG_FILE"

+ 1 - 0
server.pid

@@ -0,0 +1 @@
+198578

+ 22 - 0
test.sh

@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+PORT=8501
+BASE_URL="http://127.0.0.1:$PORT"
+
+echo "Checking /"
+curl -fsS "$BASE_URL/"
+
+echo
+
+echo "Calling MCP tool list_graphs"
+curl -sSf -X POST "$BASE_URL/mcp" \
+  -H "Content-Type: application/json" \
+  -d '{"tool":"list_graphs","input":{}}'
+
+echo
+
+echo "Tests passed (assuming HTTP 200 responses)"

+ 138 - 0
virtuoso_mcp.py

@@ -0,0 +1,138 @@
+import logging
+import os
+import re
+from typing import Any, Dict
+
+import requests
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger("virtuoso_mcp")
+
+app = FastAPI(title="MCP Server")
+
+# --- CONFIG ---
+VIRTUOSO_SPARQL = os.getenv("VIRTUOSO_SPARQL", "http://localhost:8891/sparql")
+SPARQL_TIMEOUT = float(os.getenv("SPARQL_TIMEOUT", 10.0))
+SESSION = requests.Session()
+
+# --- MODELS ---
+class SparqlQueryRequest(BaseModel):
+    query: str
+
+
+class ToolRequest(BaseModel):
+    tool: str
+    input: Dict[str, Any] = {}
+
+
+# --- CORE SPARQL FUNCTION ---
+
+def run_sparql(query: str) -> Dict[str, Any]:
+    """Execute a SPARQL query against Virtuoso and return the JSON payload."""
+    logger.debug("Sending SPARQL query: %s", query)
+    try:
+        response = SESSION.post(
+            VIRTUOSO_SPARQL,
+            data={"query": query},
+            headers={"Accept": "application/sparql-results+json"},
+            timeout=SPARQL_TIMEOUT,
+        )
+        response.raise_for_status()
+        return response.json()
+    except Exception as exc:  # pragma: no cover - propagate for FastAPI
+        logger.warning("SPARQL request failed: %s", exc)
+        raise HTTPException(status_code=500, detail=str(exc))
+
+
+# --- TOOL HELPERS ---
+
+def sanitize_term(term: str) -> str:
+    """Escape quotes inside label searches so we can safely interpolate strings."""
+    return re.sub(r"\"", "\\\"", term)
+
+
+# --- MCP TOOL IMPLEMENTATIONS ---
+
+def tool_sparql_query(input_data: Dict[str, Any]) -> Dict[str, Any]:
+    query = input_data.get("query")
+    if not query:
+        raise ValueError("Missing 'query' field")
+    return run_sparql(query)
+
+
+def tool_list_graphs(_input: Dict[str, Any]) -> Dict[str, Any]:
+    query = """
+    SELECT DISTINCT ?g WHERE {
+        GRAPH ?g { ?s ?p ?o }
+    }
+    LIMIT 50
+    """
+    return run_sparql(query)
+
+
+def tool_search_label(input_data: Dict[str, Any]) -> Dict[str, Any]:
+    term = input_data.get("term", "")
+    sanitized = sanitize_term(term)
+    query = f"""
+    SELECT ?s ?label WHERE {{
+        ?s rdfs:label ?label .
+        FILTER(CONTAINS(LCASE(?label), LCASE(\"{sanitized}\")))
+    }}
+    LIMIT 20
+    """
+    return run_sparql(query)
+
+
+# --- TOOL REGISTRY ---
+TOOLS = {
+    "sparql_query": tool_sparql_query,
+    "list_graphs": tool_list_graphs,
+    "search_label": tool_search_label,
+}
+
+TOOL_DOCS = {
+    "sparql_query": "Execute arbitrary SPARQL and return the JSON result.",
+    "list_graphs": "List up to 50 active graph URIs.",
+    "search_label": "Search rdfs:label values that contain a term (case-insensitive).",
+}
+
+
+# --- MCP ENDPOINT ---
+
+@app.post("/mcp")
+def handle_mcp(request: ToolRequest):
+    tool_name = request.tool
+    input_data = request.input or {}
+
+    if tool_name not in TOOLS:
+        raise HTTPException(status_code=400, detail=f"Unknown tool: {tool_name}")
+
+    try:
+        result = TOOLS[tool_name](input_data)
+        return {
+            "status": "ok",
+            "tool": tool_name,
+            "description": TOOL_DOCS.get(tool_name, ""),
+            "result": result,
+        }
+    except Exception as exc:
+        logger.error("Tool %s failed: %s", tool_name, exc)
+        raise HTTPException(status_code=500, detail=str(exc))
+
+
+# --- HEALTH CHECK ---
+
+@app.get("/")
+def root():
+    return {
+        "status": "MCP server running",
+        "tools": list(TOOLS.keys()),
+        "virtuoso": VIRTUOSO_SPARQL,
+    }
+
+
+@app.get("/health")
+def health():
+    return root()