فهرست منبع

Sanitize MCP fixtures/tests and move pid to logs

Lukas Goldschmidt 1 ماه پیش
والد
کامیت
a9bea23ac6
10فایلهای تغییر یافته به همراه249 افزوده شده و 56 حذف شده
  1. 0 2
      .gitignore
  2. 31 0
      DESIGN_NOTES.md
  3. 5 0
      PROJECT.md
  4. 3 3
      README.md
  5. 94 0
      examples/catalog_fixture.ttl
  6. 17 14
      killserver.sh
  7. 5 1
      run.sh
  8. 0 1
      server.pid
  9. 57 28
      test.sh
  10. 37 7
      virtuoso_mcp.py

+ 0 - 2
.gitignore

@@ -14,5 +14,3 @@ venv/
 
 
 # VSCode
 # VSCode
 .vscode/
 .vscode/
-
-examples

+ 31 - 0
DESIGN_NOTES.md

@@ -0,0 +1,31 @@
+# Virtuoso MCP & Garden Layer design notes
+
+## Observations from `test.sh`
+- The test drive beats through the /mcp tools (list_graphs, search_label, get_entities_by_type, ...). Every section assumes the graph contains catalog items, categories, cycles, and a `derivedFrom` relationship for lineage-style traversal.
+- The `derivedFrom` traversal, property usage stats, and path helpers expect a small fixture dataset so their queries return bindings rather than empty results.
+- Without the fixture, only the synthetic `batch_insert`/`insert_triple` checks succeed; the rest rely on the loaded example graph.
+
+## Immediate plan
+1. Fabricate a deterministic fixture in the MCP repo (`virtuoso_mcp/examples/catalog_fixture.ttl`) that models an innocuous domain (catalog items). The model should include:
+   - A cycle-like class and a handful of items tied together with generic relationships.
+   - Labels, descriptions, and simple lineage properties so `get_entities_by_type`, `search_label`, `traverse_property`, and `path_traverse` have meaningful hits.
+2. Keep `garden_layer` tests pointed at the ganja domain data while ensuring MCP tests only load the harmless catalog fixture.
+3. Adjust `virtuoso_mcp/test.sh` to load the fixture (via the MCP `load_examples` tool) before running the queries—this ensures the tests no longer depend on an out-of-band dataset and remain legal/harmless.
+4. Extend `garden_layer/test_garden_layer.py` to optionally load a domain fixture when `MCP_ALLOW_EXAMPLE_LOAD` is true and to assert the ganja data gives the expected clones, labels, and cycle members.
+
+## Design ambitions & notes for future work
+- **Strict separation:** Keep the MCP server as a generic toolset (traverse_property, describe_subject, property_usage_statistics, etc.) and let `garden_layer` orchestrate those atomics into higher-level workflows. No MCP tools should hard-code domain-specific strings; those belong in the domain layer config/tests.
+- **Pedigree builder:** Create a sequential helper (e.g., `GardenLayer.pedigree(subject_uri, depth=3, include_siblings=True)`) that uses `traverse_property` + `describe_subject` + `property_usage_statistics` to collect ancestors and the sibling labels/metadata most helpful for downstream agents.
+- **Descriptive label resolver:** Add a helper that cycles through `cloneOf` siblings/parents to pick the most descriptive `rdfs:label`/`dc:description` so agents can surface the best wording when summarizing a clone.
+- **Tool chaining narrative:** Document the envisioned flow where an agent asks for a clone pedigree, the domain layer calls the atomic MCP tools in sequence, and the agent sees a structured response (list of ancestors, best labels, cycle membership). Keep this description in `virtuoso_mcp/DESIGN_NOTES.md` so we can revisit it when building the next iteration.
+- **Test data hygiene:** Any new fixtures should be clearly named/dated and loadable via `MCP_ALLOW_EXAMPLE_LOAD`. That makes the data easy to reset between runs and avoids accidentally shipping sensitive domain data.
+
+## Domain vs test data reminder
+- `garden_layer` represents the ganja breeding project; we keep its configuration and helpers pointed at that domain so the real use case remains visible.
+- The MCP core and its test fixtures stay generic—roses/fish/wine or other harmless examples—so the reusable tools can be consumed without dragging domain-specific terminology into every test run.
+
+## Next concrete steps
+- Design the catalog fixture TTL (classes, relationships, labels).
+- Create loader automation: a short script or `test.sh` snippet that invokes `/mcp` → `load_examples` when `MCP_ALLOW_EXAMPLE_LOAD=true` before other tests run.
+- Expand `GardenLayer` with the pedigree/descriptive helpers and add matching domain-layer tools so OpenClaw agents can call them.
+- Keep this file updated with outcomes so we have a historical log of why we structured things this way.

+ 5 - 0
PROJECT.md

@@ -22,6 +22,11 @@ Build a minimal MCP server that proxies Virtuoso Community Edition SPARQL endpoi
 - `load_examples`: optionally load Turtle example files from `examples/` into a graph (guarded by `MCP_ALLOW_EXAMPLE_LOAD=true`).
 - `load_examples`: optionally load Turtle example files from `examples/` into a graph (guarded by `MCP_ALLOW_EXAMPLE_LOAD=true`).
 - Later add more semantic tools (predicate discovery, ontology hints) rather than letting the agent write arbitrary SPARQL.
 - Later add more semantic tools (predicate discovery, ontology hints) rather than letting the agent write arbitrary SPARQL.
 
 
+## Testing data policy
+
+- Fixtures loaded through `load_examples` must describe harmless domains (roses, fish, wine bottles, etc.) so the MCP server tests remain generic and easy to share.
+- Actual garden/ganja breeding data stays in the `garden_layer` domain plugin; we do not reuse those URIs in the core MCP toolset or pretend the generic fixtures are the same dataset.
+
 ## Stage 3 — Schema Awareness & Introspection
 ## Stage 3 — Schema Awareness & Introspection
 
 
 - Tools for predicate discovery and class hierarchy.
 - Tools for predicate discovery and class hierarchy.

+ 3 - 3
README.md

@@ -37,7 +37,7 @@ MCP Server
 - `SPARQL_DEFAULT_LIMIT`
 - `SPARQL_DEFAULT_LIMIT`
 - `SPARQL_MAX_LIMIT`
 - `SPARQL_MAX_LIMIT`
 - `MCP_ALLOW_EXAMPLE_LOAD` (`true`/`false`)
 - `MCP_ALLOW_EXAMPLE_LOAD` (`true`/`false`)
-- `EXAMPLE_GRAPH` (graph URI for `load_examples`)
+- `EXAMPLE_GRAPH` (graph URI for `load_examples`, default `http://example.org/catalog#test`)
 
 
 ## Design Principles
 ## Design Principles
 
 
@@ -54,7 +54,7 @@ MCP Server
 
 
 ## Example loading (test instances)
 ## Example loading (test instances)
 
 
-Set `MCP_ALLOW_EXAMPLE_LOAD=true` to enable the `load_examples` tool. It loads Turtle files from `examples/` into the `EXAMPLE_GRAPH` (default `http://world.eu.org/cannabis-breeding#test`). This is meant for test instances only.
+Set `MCP_ALLOW_EXAMPLE_LOAD=true` to enable the `load_examples` tool. It loads Turtle fixtures (e.g., `examples/catalog_fixture.ttl`) into the `EXAMPLE_GRAPH` (default `http://example.org/catalog#test`). This is meant for test instances only and uses harmless sample data.
 
 
 **Note:** the example files are Turtle (`.ttl`) and the loader sends them as SPARQL Update with Turtle prefixes preserved.
 **Note:** the example files are Turtle (`.ttl`) and the loader sends them as SPARQL Update with Turtle prefixes preserved.
 
 
@@ -83,7 +83,7 @@ Set `MCP_ALLOW_EXAMPLE_LOAD=true` to enable the `load_examples` tool. It loads T
 
 
 ### Update/test helpers
 ### Update/test helpers
 - `insert_triple` (single-triple update helper)
 - `insert_triple` (single-triple update helper)
-- `load_examples` (optional; requires `MCP_ALLOW_EXAMPLE_LOAD=true`)
+- `load_examples` (optional; requires `MCP_ALLOW_EXAMPLE_LOAD=true`; loads fixtures such as `examples/catalog_fixture.ttl`)
 
 
 ## Layering recommendation
 ## Layering recommendation
 
 

+ 94 - 0
examples/catalog_fixture.ttl

@@ -0,0 +1,94 @@
+@prefix ex: <http://example.org/catalog#> .
+@prefix rel: <http://example.org/relations#> .
+@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix dc: <http://purl.org/dc/elements/1.1/> .
+
+ex:Item a rdfs:Class ;
+    rdfs:label "Item" ;
+    rdfs:comment "Generic catalog item." .
+
+ex:Category a rdfs:Class ;
+    rdfs:label "Category" ;
+    rdfs:comment "Grouping for catalog items." .
+
+ex:ReleaseCycle a rdfs:Class ;
+    rdfs:label "Release cycle" ;
+    rdfs:comment "Cycle used for grouping releases." .
+
+ex:Batch a rdfs:Class ;
+    rdfs:label "Batch" ;
+    rdfs:comment "Processing batch." .
+
+rel:inCycle a rdf:Property ;
+    rdfs:label "in cycle" ;
+    rdfs:domain ex:Item ;
+    rdfs:range ex:ReleaseCycle .
+
+rel:belongsToCategory a rdf:Property ;
+    rdfs:label "belongs to category" ;
+    rdfs:domain ex:Item ;
+    rdfs:range ex:Category .
+
+rel:derivedFrom a rdf:Property ;
+    rdfs:label "derived from" ;
+    rdfs:comment "Indicates an item derived from another item." ;
+    rdfs:domain ex:Item ;
+    rdfs:range ex:Item .
+
+rel:sourceBatch a rdf:Property ;
+    rdfs:label "source batch" ;
+    rdfs:domain ex:Item ;
+    rdfs:range ex:Batch .
+
+rel:originBatch a rdf:Property ;
+    rdfs:label "origin batch" ;
+    rdfs:domain ex:Batch ;
+    rdfs:range ex:Batch .
+
+ex:Cycle_2026 a ex:ReleaseCycle ;
+    rdfs:label "Cycle 2026" ;
+    dc:description "Sample release cycle." .
+
+ex:Category_Alpha a ex:Category ;
+    rdfs:label "Alpha series" ;
+    dc:description "Example category for the catalog." .
+
+ex:Batch_Starter a ex:Batch ;
+    rdfs:label "Starter batch 2026" ;
+    dc:description "Initial processing batch." ;
+    rel:originBatch ex:Batch_Origin .
+
+ex:Batch_Origin a ex:Batch ;
+    rdfs:label "Origin batch 2026" ;
+    dc:description "Original batch used for lineage." .
+
+ex:Item_Prototype a ex:Item ;
+    rdfs:label "Prototype Item" ;
+    dc:description "Baseline item used for derived variants." ;
+    rel:inCycle ex:Cycle_2026 ;
+    rel:belongsToCategory ex:Category_Alpha .
+
+ex:Item_Prime a ex:Item ;
+    rdfs:label "Prime Variant" ;
+    dc:description "A refined variant derived from the prototype." ;
+    rel:inCycle ex:Cycle_2026 ;
+    rel:belongsToCategory ex:Category_Alpha ;
+    rel:derivedFrom ex:Item_Prototype ;
+    rel:sourceBatch ex:Batch_Starter .
+
+ex:Item_Amber a ex:Item ;
+    rdfs:label "Amber Variant" ;
+    dc:description "Variant with amber coloration." ;
+    rel:inCycle ex:Cycle_2026 ;
+    rel:belongsToCategory ex:Category_Alpha ;
+    rel:derivedFrom ex:Item_Prototype ;
+    rel:sourceBatch ex:Batch_Starter .
+
+ex:Item_Amber_II a ex:Item ;
+    rdfs:label "Amber Variant II" ;
+    dc:description "Second-generation amber variant." ;
+    rel:inCycle ex:Cycle_2026 ;
+    rel:belongsToCategory ex:Category_Alpha ;
+    rel:derivedFrom ex:Item_Amber ;
+    rel:sourceBatch ex:Batch_Starter .

+ 17 - 14
killserver.sh

@@ -4,26 +4,29 @@ set -euo pipefail
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 cd "$SCRIPT_DIR"
 cd "$SCRIPT_DIR"
 
 
-PID_FILE="server.pid"
+PID_FILE="logs/server.pid"
+LEGACY_PID_FILE="server.pid"
 PORT="${MCP_PORT:-8501}"
 PORT="${MCP_PORT:-8501}"
 
 
 echo "[killserver] Checking for running virtuoso_mcp instances..."
 echo "[killserver] Checking for running virtuoso_mcp instances..."
 
 
-if [[ -f "$PID_FILE" ]]; then
-  PID="$(cat "$PID_FILE" 2>/dev/null || true)"
-  if [[ -n "${PID:-}" ]] && kill -0 "$PID" 2>/dev/null; then
-    echo "[killserver] Stopping PID from pidfile: $PID"
-    kill "$PID" || true
-    sleep 0.5
-    if kill -0 "$PID" 2>/dev/null; then
-      echo "[killserver] PID $PID still alive, sending SIGKILL"
-      kill -9 "$PID" || true
+for pidfile in "$PID_FILE" "$LEGACY_PID_FILE"; do
+  if [[ -f "$pidfile" ]]; then
+    PID="$(cat "$pidfile" 2>/dev/null || true)"
+    if [[ -n "${PID:-}" ]] && kill -0 "$PID" 2>/dev/null; then
+      echo "[killserver] Stopping PID from pidfile: $PID"
+      kill "$PID" || true
+      sleep 0.5
+      if kill -0 "$PID" 2>/dev/null; then
+        echo "[killserver] PID $PID still alive, sending SIGKILL"
+        kill -9 "$PID" || true
+      fi
+    else
+      echo "[killserver] Stale or empty pidfile, removing."
     fi
     fi
-  else
-    echo "[killserver] Stale or empty pidfile, removing."
+    rm -f "$pidfile"
   fi
   fi
-  rm -f "$PID_FILE"
-fi
+done
 
 
 STRAY_PIDS="$(ps -ef | grep -E 'uvicorn[[:space:]]+virtuoso_mcp:app' | grep -v grep | awk '{print $2}' || true)"
 STRAY_PIDS="$(ps -ef | grep -E 'uvicorn[[:space:]]+virtuoso_mcp:app' | grep -v grep | awk '{print $2}' || true)"
 if [[ -n "${STRAY_PIDS:-}" ]]; then
 if [[ -n "${STRAY_PIDS:-}" ]]; then

+ 5 - 1
run.sh

@@ -18,9 +18,13 @@ fi
 
 
 LOG_DIR="logs"
 LOG_DIR="logs"
 mkdir -p "$LOG_DIR"
 mkdir -p "$LOG_DIR"
-PID_FILE="server.pid"
+PID_FILE="$LOG_DIR/server.pid"
 LOG_FILE="$LOG_DIR/server.log"
 LOG_FILE="$LOG_DIR/server.log"
 
 
+if [[ -f "server.pid" ]]; then
+  rm -f "server.pid"
+fi
+
 if [[ -f "$PID_FILE" ]]; then
 if [[ -f "$PID_FILE" ]]; then
   PID=$(cat "$PID_FILE")
   PID=$(cat "$PID_FILE")
   if kill -0 "$PID" >/dev/null 2>&1; then
   if kill -0 "$PID" >/dev/null 2>&1; then

+ 0 - 1
server.pid

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

+ 57 - 28
test.sh

@@ -13,7 +13,15 @@ fi
 
 
 PORT=8501
 PORT=8501
 BASE_URL="http://127.0.0.1:$PORT"
 BASE_URL="http://127.0.0.1:$PORT"
-TEST_GRAPH="http://world.eu.org/cannabis-breeding#test"
+TEST_GRAPH="${EXAMPLE_GRAPH:-http://example.org/catalog#test}"
+DERIVED_PROPERTY="http://example.org/relations#derivedFrom"
+SOURCE_BATCH_PROPERTY="http://example.org/relations#sourceBatch"
+ORIGIN_BATCH_PROPERTY="http://example.org/relations#originBatch"
+CATEGORY_TYPE="http://example.org/catalog#Category"
+ROOT_ITEM="http://example.org/catalog#Item_Prototype"
+PATH_SUBJECT="http://example.org/catalog#Item_Prime"
+SAMPLE_FIXTURE="catalog_fixture.ttl"
+: "${MCP_ALLOW_EXAMPLE_LOAD:=true}"
 
 
 if ! command -v jq >/dev/null 2>&1; then
 if ! command -v jq >/dev/null 2>&1; then
   echo "ERROR: jq is required for test output parsing."
   echo "ERROR: jq is required for test output parsing."
@@ -76,6 +84,18 @@ assert_tool_ok() {
   return 0
   return 0
 }
 }
 
 
+section "Load sample dataset"
+if [[ "${MCP_ALLOW_EXAMPLE_LOAD:-false}" == "true" ]]; then
+  payload=$(jq -n --arg file "$SAMPLE_FIXTURE" --arg graph "$TEST_GRAPH" '{"tool":"load_examples","input":{"files":[$file],"graph":$graph}}')
+  TOOL_LAST_RESPONSE=""
+  if assert_tool_ok "load_examples" "$payload"; then
+    echo "Loaded fixtures:"
+    echo "$TOOL_LAST_RESPONSE" | jq -r '.result.loaded[] | "  - " + (.file + " → " + .graph)' || true
+  fi
+else
+  echo "MCP_ALLOW_EXAMPLE_LOAD not true; skipping sample dataset load."
+fi
+
 section "Health check"
 section "Health check"
 root_json="$(curl -sS "$BASE_URL/")"
 root_json="$(curl -sS "$BASE_URL/")"
 echo "$root_json" | jq '{status, virtuoso, tools, guardrails}'
 echo "$root_json" | jq '{status, virtuoso, tools, guardrails}'
@@ -94,7 +114,8 @@ fi
 
 
 section "Update path (fabricated triples)"
 section "Update path (fabricated triples)"
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "insert_triple" '{"tool":"insert_triple","input":{"subject":"http://example.org/plain#TestPlant1","predicate":"http://www.w3.org/1999/02/22-rdf-syntax-ns#type","object":"http://example.org/plain#Specimen","object_type":"uri","graph":"http://world.eu.org/cannabis-breeding#test"}}'; then
+payload=$(jq -n --arg subject "http://example.org/plain#TestItem1" --arg predicate "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" --arg object "http://example.org/plain#Specimen" --arg graph "$TEST_GRAPH" '{"tool":"insert_triple","input":{"subject":$subject,"predicate":$predicate,"object":$object,"object_type":"uri","graph":$graph}}')
+if assert_tool_ok "insert_triple" "$payload"; then
   echo "Inserted query preview:"
   echo "Inserted query preview:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.query' | sed 's/^/  /'
   echo "$TOOL_LAST_RESPONSE" | jq -r '.query' | sed 's/^/  /'
 fi
 fi
@@ -102,8 +123,8 @@ fi
 EXAMPLE_TTL=$(cat <<'EOF'
 EXAMPLE_TTL=$(cat <<'EOF'
 @prefix ex: <http://example.org/plain#> .
 @prefix ex: <http://example.org/plain#> .
 @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
 @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-ex:PlainPlant a ex:Specimen ;
-    rdfs:label "Plain test plant" .
+ex:PlainItem a ex:Specimen ;
+    rdfs:label "Plain test item" .
 EOF
 EOF
 )
 )
 
 
@@ -116,80 +137,88 @@ fi
 
 
 section "Helper retrieval checks"
 section "Helper retrieval checks"
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "get_entities_by_type(Strain)" '{"tool":"get_entities_by_type","input":{"type_uri":"http://world.eu.org/cannabis-breeding#Strain","limit":5}}'; then
+payload=$(jq -n --arg type_uri "$CATEGORY_TYPE" --argjson limit 5 '{"tool":"get_entities_by_type","input":{"type_uri":$type_uri,"limit":$limit}}')
+if assert_tool_ok "get_entities_by_type(Category)" "$payload"; then
   echo "Entity IRIs:"
   echo "Entity IRIs:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[].s.value' | sed 's/^/  - /' || true
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[].s.value' | sed 's/^/  - /' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "search_label(term=King)" '{"tool":"search_label","input":{"term":"King","limit":5}}'; then
+payload=$(jq -n --arg term "Variant" --argjson limit 5 '{"tool":"search_label","input":{"term":$term,"limit":$limit}}')
+if assert_tool_ok "search_label(term=Variant)" "$payload"; then
   echo "Label hits:"
   echo "Label hits:"
-  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - \(.label.value) (\(.s.value))"' || true
+  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - " + (.label.value // "<no label>") + " (" + .s.value + ")"' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "get_predicates_for_subject(Strain_king_kong)" '{"tool":"get_predicates_for_subject","input":{"subject_uri":"http://world.eu.org/example1#Strain_king_kong","limit":10}}'; then
+payload=$(jq -n --arg subject_uri "$PATH_SUBJECT" --argjson limit 10 '{"tool":"get_predicates_for_subject","input":{"subject_uri":$subject_uri,"limit":$limit}}')
+if assert_tool_ok "get_predicates_for_subject(Prime Item)" "$payload"; then
   echo "Predicates found:"
   echo "Predicates found:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[].p.value' | sed 's/^/  - /' || true
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[].p.value' | sed 's/^/  - /' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "get_labels_for_subject(Strain_king_kong)" '{"tool":"get_labels_for_subject","input":{"subject_uri":"http://world.eu.org/example1#Strain_king_kong"}}'; then
+payload=$(jq -n --arg subject_uri "$PATH_SUBJECT" '{"tool":"get_labels_for_subject","input":{"subject_uri":$subject_uri}}')
+if assert_tool_ok "get_labels_for_subject(Prime Item)" "$payload"; then
   echo "Subject labels:"
   echo "Subject labels:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[].label.value' | sed 's/^/  - /' || true
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[].label.value' | sed 's/^/  - /' || true
 fi
 fi
 
 
-section "Clone inspection"
-KEROSENE_ROOT="http://world.eu.org/example1#Plant_90d53925-bb5"
+section "Relationship inspection"
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "traverse_property(cloneOf incoming)" '{"tool":"traverse_property","input":{"subject_uri":"http://world.eu.org/example1#Plant_90d53925-bb5","property_uri":"http://world.eu.org/cannabis-breeding#cloneOf","direction":"incoming","limit":20}}'; then
-  echo "Clones of Kerosene Krash root:"
-  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - \(.neighbor.value) (label: \(.label.value // "<no label>"))"'
+payload=$(jq -n --arg subject_uri "$ROOT_ITEM" --arg property_uri "$DERIVED_PROPERTY" --arg direction "incoming" --argjson limit 20 '{"tool":"traverse_property","input":{"subject_uri":$subject_uri,"property_uri":$property_uri,"direction":$direction,"limit":$limit}}')
+if assert_tool_ok "traverse_property(derivedFrom incoming)" "$payload"; then
+  echo "Items derived from the prototype:"
+  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - " + (.neighbor.value) + " (label: " + (.label.value // "<no label>") + ")"'
 fi
 fi
 
 
 section "Ontology discovery"
 section "Ontology discovery"
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
 if assert_tool_ok "list_classes(term=cycle)" '{"tool":"list_classes","input":{"term":"cycle","limit":10}}'; then
 if assert_tool_ok "list_classes(term=cycle)" '{"tool":"list_classes","input":{"term":"cycle","limit":10}}'; then
   echo "Class hits:"
   echo "Class hits:"
-  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - \(.class.value) (\(.label.value // "<no label>"))"' || true
+  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - " + .class.value + " (" + (.label.value // "<no label>") + ")"' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "list_properties(term=clone)" '{"tool":"list_properties","input":{"term":"clone","limit":10}}'; then
+if assert_tool_ok "list_properties(term=derived)" '{"tool":"list_properties","input":{"term":"derived","limit":10}}'; then
   echo "Property hits:"
   echo "Property hits:"
-  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - \(.property.value) (\(.label.value // "<no label>"))"' || true
+  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - " + .property.value + " (" + (.label.value // "<no label>") + ")"' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "describe_property(cb:cloneOf)" '{"tool":"describe_property","input":{"property_uri":"http://world.eu.org/cannabis-breeding#cloneOf","usage_limit":5}}'; then
-  echo "cloneOf metadata rows:"
+payload=$(jq -n --arg property_uri "$DERIVED_PROPERTY" --argjson limit 5 '{"tool":"describe_property","input":{"property_uri":$property_uri,"usage_limit":$limit}}')
+if assert_tool_ok "describe_property(derivedFrom)" "$payload"; then
+  echo "derivedFrom metadata rows:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.metadata.results.bindings | length | tostring | "  - rows: " + .'
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.metadata.results.bindings | length | tostring | "  - rows: " + .'
 fi
 fi
 
 
 section "Subject/path helpers"
 section "Subject/path helpers"
-PATH_SUBJECT="http://world.eu.org/example1#Plant_cookie_kerosene_2026_3"
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "describe_subject(cookie path)" "{\"tool\":\"describe_subject\",\"input\":{\"subject_uri\":\"${PATH_SUBJECT}\",\"limit\":10}}"; then
+payload=$(jq -n --arg subject_uri "$PATH_SUBJECT" --argjson limit 10 '{"tool":"describe_subject","input":{"subject_uri":$subject_uri,"limit":$limit}}')
+if assert_tool_ok "describe_subject(Prime Item)" "$payload"; then
   echo "Subject triples:"
   echo "Subject triples:"
-  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - \(.predicate.value) -> \(.objectLabel.value // .object.value)"' || true
+  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.results.bindings[] | "  - " + .predicate.value + " -> " + (.objectLabel.value // .object.value)' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "path_traverse(seed lineage)" "{\"tool\":\"path_traverse\",\"input\":{\"subject_uri\":\"${PATH_SUBJECT}\",\"property_path\":[\"http://world.eu.org/cannabis-breeding#grownFromSeedProduct\",\"http://world.eu.org/cannabis-breeding#seedProductFromPollination\"],\"limit\":5}}"; then
+payload=$(jq -n --arg subject_uri "$PATH_SUBJECT" --arg property1 "$SOURCE_BATCH_PROPERTY" --arg property2 "$ORIGIN_BATCH_PROPERTY" --argjson limit 5 '{"tool":"path_traverse","input":{"subject_uri":$subject_uri,"property_path":[$property1,$property2],"limit":$limit}}')
+if assert_tool_ok "path_traverse(batch lineage)" "$payload"; then
   echo "Path traverse results:"
   echo "Path traverse results:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.result.results.bindings[] | "  - " + (.n1Label.value // .n1.value) + " -> " + (.n2Label.value // .n2.value)' || true
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.result.results.bindings[] | "  - " + (.n1Label.value // .n1.value) + " -> " + (.n2Label.value // .n2.value)' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "property_usage_statistics" '{"tool":"property_usage_statistics","input":{"property_uri":"http://world.eu.org/cannabis-breeding#cloneOf","examples_limit":3}}'; then
-  echo "cloneOf usage count:"
-  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.count.results.bindings[0].usageCount.value // "0" | "  - " + .' || true
-  echo "Sample bindings:" 
+payload=$(jq -n --arg property_uri "$DERIVED_PROPERTY" --argjson examples_limit 3 '{"tool":"property_usage_statistics","input":{"property_uri":$property_uri,"examples_limit":$examples_limit}}')
+if assert_tool_ok "property_usage_statistics" "$payload"; then
+  echo "derivedFrom usage count:"
+  echo "$TOOL_LAST_RESPONSE" | jq -r '.result.count.results.bindings[0].usageCount.value // "0" | "  - " + .'
+  echo "Sample bindings:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.examples.results.bindings[] | "  - " + (.subjectLabel.value // .subject.value) + " -> " + (.objectLabel.value // .object.value)' || true
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.examples.results.bindings[] | "  - " + (.subjectLabel.value // .subject.value) + " -> " + (.objectLabel.value // .object.value)' || true
 fi
 fi
 
 
 TOOL_LAST_RESPONSE=""
 TOOL_LAST_RESPONSE=""
-if assert_tool_ok "batch_insert(test)" '{"tool":"batch_insert","input":{"ttl":"<http://example.org/plain#BatchPlant> <http://www.w3.org/2000/01/rdf-schema#label> \"batch helper\" .","graph":"http://world.eu.org/cannabis-breeding#test"}}'; then
+payload=$(jq -n --arg ttl "<http://example.org/plain#BatchItem> <http://www.w3.org/2000/01/rdf-schema#label> \"batch helper\" ." --arg graph "$TEST_GRAPH" '{"tool":"batch_insert","input":{"ttl":$ttl,"graph":$graph}}')
+if assert_tool_ok "batch_insert(test)" "$payload"; then
   echo "Batch insert query:"
   echo "Batch insert query:"
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.query' | sed 's/^/  /'
   echo "$TOOL_LAST_RESPONSE" | jq -r '.result.query' | sed 's/^/  /'
 fi
 fi

+ 37 - 7
virtuoso_mcp.py

@@ -28,12 +28,10 @@ SPARQL_TIMEOUT = float(os.getenv("SPARQL_TIMEOUT", 10.0))
 SPARQL_UPDATE_TIMEOUT = float(os.getenv("SPARQL_UPDATE_TIMEOUT", 15.0))
 SPARQL_UPDATE_TIMEOUT = float(os.getenv("SPARQL_UPDATE_TIMEOUT", 15.0))
 SPARQL_DEFAULT_LIMIT = int(os.getenv("SPARQL_DEFAULT_LIMIT", 100))
 SPARQL_DEFAULT_LIMIT = int(os.getenv("SPARQL_DEFAULT_LIMIT", 100))
 SPARQL_MAX_LIMIT = int(os.getenv("SPARQL_MAX_LIMIT", 500))
 SPARQL_MAX_LIMIT = int(os.getenv("SPARQL_MAX_LIMIT", 500))
-GRAPH_URI = os.getenv("GRAPH_URI", "http://world.eu.org/example1")
-IN_CYCLE = "http://world.eu.org/cannabis-breeding#inCycle"
-CLONE_OF = "http://world.eu.org/cannabis-breeding#cloneOf"
+GRAPH_URI = os.getenv("GRAPH_URI", "http://example.org/catalog#")
 EXAMPLES_DIR = Path(__file__).resolve().parent / "examples"
 EXAMPLES_DIR = Path(__file__).resolve().parent / "examples"
 EXAMPLE_GRAPH = os.getenv(
 EXAMPLE_GRAPH = os.getenv(
-    "EXAMPLE_GRAPH", "http://world.eu.org/cannabis-breeding#test"
+    "EXAMPLE_GRAPH", "http://example.org/catalog#test"
 )
 )
 ALLOW_EXAMPLE_LOAD = os.getenv("MCP_ALLOW_EXAMPLE_LOAD", "false").lower() == "true"
 ALLOW_EXAMPLE_LOAD = os.getenv("MCP_ALLOW_EXAMPLE_LOAD", "false").lower() == "true"
 SESSION = requests.Session()
 SESSION = requests.Session()
@@ -48,7 +46,6 @@ tool_logger.propagate = False
 
 
 PREFIXES = f"""
 PREFIXES = f"""
 PREFIX : <{GRAPH_URI}>
 PREFIX : <{GRAPH_URI}>
-PREFIX cb: <http://world.eu.org/cannabis-breeding#>
 PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
 PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
 PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
 PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
 PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
 PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
@@ -577,6 +574,39 @@ def tool_batch_insert(input_data: Dict[str, Any]) -> Dict[str, Any]:
     return {**result, "query": query}
     return {**result, "query": query}
 
 
 
 
+def tool_load_examples(input_data: Dict[str, Any]) -> Dict[str, Any]:
+    if not ALLOW_EXAMPLE_LOAD:
+        raise HTTPException(status_code=403, detail="Example loading is disabled")
+
+    requested = input_data.get("files") or []
+    if isinstance(requested, str):
+        requested = [requested]
+    graph = input_data.get("graph") or EXAMPLE_GRAPH
+
+    files_to_load = []
+    if requested:
+        files_to_load = requested
+    else:
+        files_to_load = sorted(p.name for p in EXAMPLES_DIR.glob("*.ttl"))
+
+    if not files_to_load:
+        raise HTTPException(status_code=404, detail="No example files available")
+
+    results = []
+    for filename in files_to_load:
+        file_path = (EXAMPLES_DIR / filename).resolve()
+        if not file_path.exists():
+            raise HTTPException(status_code=400, detail=f"Missing example file: {filename}")
+        if EXAMPLES_DIR not in file_path.parents and file_path != EXAMPLES_DIR:
+            raise HTTPException(status_code=400, detail="Invalid example file path")
+        ttl_text = file_path.read_text(encoding="utf-8")
+        update_query = ttl_to_sparql_insert(ttl_text, graph)
+        run_sparql_update(update_query)
+        results.append({"file": filename, "graph": graph})
+
+    return {"loaded": results}
+
+
 def tool_insert_triple(input_data: Dict[str, Any]) -> Dict[str, Any]:
 def tool_insert_triple(input_data: Dict[str, Any]) -> Dict[str, Any]:
     subject = input_data.get("subject")
     subject = input_data.get("subject")
     predicate = input_data.get("predicate")
     predicate = input_data.get("predicate")
@@ -632,6 +662,7 @@ TOOLS = {
     "property_usage_statistics": tool_property_usage_statistics,
     "property_usage_statistics": tool_property_usage_statistics,
     "batch_insert": tool_batch_insert,
     "batch_insert": tool_batch_insert,
     "insert_triple": tool_insert_triple,
     "insert_triple": tool_insert_triple,
+    "load_examples": tool_load_examples,
 }
 }
 
 
 
 
@@ -678,7 +709,6 @@ TOOL_DOCS = {
     "list_graphs": "List up to 50 active graph URIs.",
     "list_graphs": "List up to 50 active graph URIs.",
     "search_label": "Search rdfs:label values that contain a term (case-insensitive).",
     "search_label": "Search rdfs:label values that contain a term (case-insensitive).",
     "get_entities_by_type": "List subjects of a given rdf:type.",
     "get_entities_by_type": "List subjects of a given rdf:type.",
-    "cycle_plants": "List plants (with labels/clone parents) that belong to a specific cycle.",
     "get_predicates_for_subject": "List distinct predicates used by a subject.",
     "get_predicates_for_subject": "List distinct predicates used by a subject.",
     "get_labels_for_subject": "Fetch rdfs:label values for a subject.",
     "get_labels_for_subject": "Fetch rdfs:label values for a subject.",
     "traverse_property": "Traverse a property (incoming or outgoing) for a subject and return labels/descriptions.",
     "traverse_property": "Traverse a property (incoming or outgoing) for a subject and return labels/descriptions.",
@@ -690,8 +720,8 @@ TOOL_DOCS = {
     "path_traverse": "Follow a property path (list of predicates) from a subject, returning each step's nodes.",
     "path_traverse": "Follow a property path (list of predicates) from a subject, returning each step's nodes.",
     "property_usage_statistics": "Count how often a property is used and sample subjects/objects.",
     "property_usage_statistics": "Count how often a property is used and sample subjects/objects.",
     "batch_insert": "Insert multiple triples or TTL at once with a single guarded update.",
     "batch_insert": "Insert multiple triples or TTL at once with a single guarded update.",
-    "reassign_cycle": "Move a subject to another production cycle by updating its inCycle link.",
     "insert_triple": "Insert a single triple (useful for debugging updates).",
     "insert_triple": "Insert a single triple (useful for debugging updates).",
+    "load_examples": "Load Turtle fixtures from the `examples/` directory when MCP_ALLOW_EXAMPLE_LOAD=true.",
 }
 }