
yifanzhao2023/fan-enterprise-ontology-mcp
Developer toolsAn MCP server that exposes an enterprise ontology as an agent's semantic memory layer. Agents read the ontology schema, query named competency questions or raw Cypher, write validated instance data,...
Overview
An MCP server that exposes an enterprise ontology as an agent's semantic memory layer. Agents read the ontology schema, query named competency questions or raw Cypher, write validated instance data, and consume offline-inferred derived facts — all as MCP tools. Storage and query layer, agent integration, offline inference, SHACL validation are in scope for v1. Ontology alignment and knowledge extraction are planned, not built. - MCP (stdio) - Python (FastMCP) + JVM inference CLI - Neo4j - Turtle / JSON-LD (RDF); tool results use a JSON-LD-aligned @context - Jena rules (OWL 2 RL + business rules), offline - pySHACL (SHACL Core at write time, full SHACL offline) - Apache License 2.0 An enterprise ontology here is a structured business semantic model — classes, relations, properties, constraints, and axioms — plus the instance facts that conform to it.
README
fan-enterprise-ontology-mcp
Making enterprise ontology simpler. · 让企业本体更简单
An MCP server that exposes an enterprise ontology as an agent’s semantic memory layer.
Agents read the ontology schema, query named competency questions or raw Cypher, write validated instance data, and consume offline-inferred derived facts — all as MCP tools.
Status: v1 complete — storage, query, agent integration, offline inference and SHACL validation are all built and tested. Ontology alignment and knowledge extraction are planned, not built.
- Protocol: MCP (stdio)
- Language: Python (MCP SDK) + JVM inference CLI
- Storage: Neo4j
- Interchange: Turtle (RDF); tool results use a JSON-LD-aligned
@contextas a business contract layer, never as an RDF exchange format - Inference: Jena rules (OWL 2 RL + business rules), offline
- Validation: pySHACL (SHACL Core at write time, full SHACL offline)
- License: Apache License 2.0
Index
- 1. Overview
- 2. Target Users & Scenarios
- 3. Architecture
- 4. How it works
- 5. Features
- 6. Tools
- 7. Resources & Prompts
- 8. Installation
- 9. Configuration
- 10. Quick Start
- 11. Security Model
- 12. Development
- 13. Testing & Acceptance
- 14. Limitations
- 15. Roadmap
- License
1. Overview
An enterprise ontology here is a structured business semantic model — classes, relations, properties, constraints, and axioms — plus the instance facts that conform to it. Its value over a plain property graph or text index is the axioms and constraints: the model can assert that “the head of a department is necessarily the owner of that department’s work orders”, and derived facts follow from it.
This project does not build a modeling environment. It makes a human-authored ontology (in Git) usable by agents at runtime, and keeps the instance store consistent with that ontology through validation and offline inference.
TBox in Git, ABox in Neo4j. The authoritative ontology is versioned Turtle/JSON-LD. Neo4j holds instances plus two derived artifact classes. The schema an agent reads is a flat projection generated from the TBox by CI.
2. Target Users & Scenarios
Particularly suitable for regulated environments: state-owned enterprises, public institutions, and universities where auditability, standard semantics, and change traceability are required.
Who this is for
- Knowledge / ontology engineers who can author and maintain Turtle, SHACL shapes, and Jena rules. The TBox lives in Git and is human-owned.
- Agent builders on Claude Code, Codex, or a custom MCP client who need structured business facts rather than text retrieval.
- Data governance / MDM teams that need auditable, constraint-checked business facts with violations reported back.
- Platform engineers who already operate Neo4j and can run a JVM-side batch job.
Use it when
- An agent must answer business questions over entities and relations — contracts, customers, work orders, org structure — not over document text.
- Terms are ambiguous and the ontology has to pin them down (external customer vs. internal customer).
- Queries carry real business predicates: amount > 1M, not archived, owned by X, in department Y.
- Facts must be checkable and auditable, with constraint violations surfaced rather than silently stored.
- You want the ontology as an agent’s semantic memory layer, not as a search index.
Do not use it when
- The task is document Q&A over unstructured text — use a vector store / RAG.
- You only need entity-relation storage with no axioms or constraints — a property graph with a light schema is cheaper.
- You need ontology alignment or knowledge extraction from source systems — out of v1 scope (see Roadmap).
- You need online OWL DL reasoning at request time — inference here is offline and batch.
- You have no JVM and cannot install Java —
onto inferrequires one. - You need graphical ontology modeling — use Protégé. This project deliberately does not model.
3. Architecture
Naming: onto infer is the Python CLI users run (export → shell out to the JVM → validate → apply). rules-runner is the JVM pure function (Turtle in → Turtle out). Swapping the reasoner replaces rules-runner only; onto infer does not change.
Deliverables
| # | Artifact | Role |
|---|---|---|
| 1 | MCP server (Python) | Schema supply, query catalog, raw Cypher, controlled writes, write-time SHACL Core |
| 2 | onto infer (Python CLI) + rules-runner (JVM) |
Orchestrates export → JVM inference → validate → apply. The JVM side holds no Neo4j driver, no DB credentials, and no write access — files only |
| 3 | ontology/ (Git) |
TBox, shapes, rules, query catalog, schema projection (generated, CI-gated) |
| 4 | Skills + MCP prompts | Claude Code skill wraps invocation habits; Codex reads MCP prompts/resources |
ontology/ships a worked example, not your ontology. The files here model one sample domain — enterprise contract management — so the server runs out of the box and the CI suite has something to regress against. ReplaceTBox.ttl,shapes.ttl,queries/*.cypherandmanifest.jsonwith your own; no Python changes are needed. After editing the TBox, runonto projectto regenerateschema.projection.json(CI fails otherwise), and register every new query inmanifest.json—Catalog.loadcross-checks each entry’susesagainst the projection and refuses to start on a term that is not there.
Data layout
| Location | Content | Written by |
|---|---|---|
Git TBox.ttl / shapes.ttl / rules/ |
Authoritative ontology and rules | Human (Protégé / LLM + review) |
Git schema.projection.json |
Flat query-side projection — classes, properties, relations, cardinality, core constraints. Not the full set of OWL axioms, and never fed to the reasoner | CI runs onto project; git diff --exit-code gate |
Git queries/*.cypher + manifest.json |
Query catalog = Competency Question set | Human + CI gate |
| Neo4j ABox | Instances | MCP controlled ingest (agent-writable) |
Neo4j :INFERRED / {inferred:true} |
Derived types and derived relations | onto infer batch job |
Neo4j :ConstraintViolation |
Offline SHACL violations, linked to the offending instance | Same batch job, cleared and rebuilt |
Three semantic node classes in Neo4j. No TBox nodes, no schema nodes.
Modeling contract
v1 TBox is locked to the Jena OWL 2 RL subset. Do not introduce OWL DL constructs beyond RL. Arithmetic business conditions live in Jena Rules, never in TBox axioms.
This is checked mechanically in CI (see Testing & Acceptance). It also settles reasoner selection: within RL, Jena rules is complete — no ELK, no HermiT in v1.
4. How it works
Chain A — Agent writes ABox
- Agent calls
upsert_entities/upsert_relationswith structured parameters, not Cypher. validate_coreruns the SHACL Core subset only:sh:minCount,sh:maxCount,sh:datatype,sh:class. It selects node shapes whosesh:targetClassmatches the written types and runs on the affected subgraph only. Synchronous.- The representation writer assigns/reuses IRIs (the
uriproperty is the entity unique key), maps properties to datatype vs. object properties, aligns labels withrdf:type. - Returns
WriteReceipt: written IRIs, skipped items, validation summary.
Two hard constraints:
- Batch atomicity is a Neo4j single transaction. Whole batch commits or whole batch rolls back — database-level, not application-level. The API caps batch size and rejects oversized batches with a hint to split; chunked writes would break atomicity and are not offered.
- Write does not update
:INFERRED. Derived facts stay stale until the nextonto inferrun (see Staleness).
SPARQL-based SHACL (SHACL-AF) never runs on the write path — too slow, and write-time only answers “can this record enter”.
Chain B — Agent queries
Every response carries @context, truncated, row_count.
@context is a business contract layer, not strict standard JSON-LD. It exists so the agent understands entity semantics. It is not an RDF interchange format. Real JSON-LD / Turtle only travels over the file chain (abox.ttl, inferred.ttl, violations.ttl).
Errors are structured and actionable:
| code | hint gives |
|---|---|
SCHEMA_NOT_FOUND |
Run search_schema; nearest candidates |
QUERY_SYNTAX |
Neo4j’s own message + “check labels and relationship names via get_schema()” |
QUERY_REJECTED |
Which guard rule fired |
QUERY_LIMIT |
Add LIMIT, narrow filters, use index-friendly predicates |
VALIDATION_FAILED |
Field-level violations: shape / path / message / focus |
WRITE_REJECTED |
Split into smaller batches |
BACKEND_UNAVAILABLE |
Retry later; no retry storm |
Chain C — Offline inference (onto infer)
Four steps. The JVM does only step 2.
Order is not negotiable: validation must follow inference (otherwise derived facts are never checked); apply must be last (otherwise agents read half-built state).
File contract
| File | Content | Producer |
|---|---|---|
abox.ttl |
Full ABox as RDF | materialize_rdf (rdflib) |
inferred.ttl |
Derived triples only — no original ABox | JVM |
violations.ttl |
SHACL violations: focus node IRI / shape IRI / path / message | pySHACL |
inferred.ttl being incremental-only is mandatory. Without it replace_derived cannot tell derived from original and would delete real facts during rebuild. The JVM’s output semantics is “triples produced by the rules and absent from the input graph”.
JVM never writes to Neo4j
rules-runner’s JVM process does not hold a Neo4j driver, does not hold DB credentials, and has no write access. It reads and writes files only. The Python orchestration layer (onto infer) owns all writes.
Practical payoff: least privilege (an easier security review in regulated environments), JVM-side unit-testable without a database, reproducible inference (same inputs, same outputs), and swapping the reasoner means replacing step 2 only.
Reproducibility
An inference job’s inputs are fingerprinted and recorded on the single metadata node: git commit SHA of TBox.ttl / rules/ / shapes.ttl, content hashes of abox.ttl / inferred.ttl / violations.ttl / shapes.ttl, the Neo4j version, and all counts.
onto inferreads TBox/rules/shapes from a git checkout- Dirty working tree → refuse to run;
--allow-dirtyruns and marks the metadata - Reproducibility means: replaying the same three input files yields the same output
Using a newer TBox against an older ABox is normal ontology evolution, not a bug. The risk is leaving no trace; fingerprinting is what makes a run auditable.
Staleness semantics
Between a write and the next onto infer, :INFERRED is stale. Three places handle this explicitly:
- Query results do not hide staleness. The agent decides whether the answer depends on derived facts.
replace_derivedwrites a singleton metadata node (job_id,finished_at, input counts, derived count, violation count) so agents can ask how fresh the derived layer is. One node — not a fourth semantic class.- Trigger cadence is an ops decision. Docs suggest a default (hourly, or debounced N minutes after writes); nothing is hard-coded.
Known risk
materialize_rdf memory on large instance exports. The exporter loads the whole graph into one rdflib model; if a real instance set shows pressure, the fallback is a per-label partitioned export feeding the JVM multiple files. The contract assumes a single abox.ttl for now.
5. Features
Schema supply (token-cost aware)
search_schema/get_schema/get_constraintsread from the flat projection file — zero database round-trips- Result shapes are compact: class names, relation names, cardinality, core constraints
- The full projection is exposed only as a resource for humans and never enters a prompt
Query catalog = Competency Questions
Named queries live in Git as parameterized Cypher with a manifest. The same artifact serves two purposes:
- CI regression — every PR runs the catalog against sample data and compares against expected results
- Agent runtime —
list_queries()/run_query(name, params)for high-frequency intents
Raw Cypher (query_raw) remains the escape hatch for ad-hoc combinations.
Controlled writes
Agents write instances through structured parameters. There is no Cypher write path, so the write-side injection surface does not exist. Write-time SHACL Core validation rejects non-conforming batches atomically.
Offline inference with derived-fact isolation
Jena rules produce derived types and derived relations. They are materialized into Neo4j under an explicit marker and rebuilt wholesale per job, never mixed into the ABox.
Violation reporting
Offline SHACL violations land in Neo4j as :ConstraintViolation nodes linked to the offending instance, so an agent can answer “what is wrong with this record” — not just “is something wrong”.
6. Tools
Ten tools, five groups.
| # | Tool | Purpose | Source |
|---|---|---|---|
| 1 | search_schema(keywords, limit=20) |
Keyword search over classes / properties / relations | Projection file |
| 2 | get_schema(entity_types[]) |
Full schema for given types: properties, cardinality, in/out relations | Projection file |
| 3 | get_constraints(entity_types[]) |
SHACL Core subset constraints for pre-write judgment | Projection file |
| 4 | list_queries(domain?) |
List named queries (= CQs) with natural-language description and params | Manifest |
| 5 | run_query(name, params) |
Run a named query | QueryEngine |
| 6 | query_raw(cypher, params) |
Raw Cypher escape hatch (read-only + guard) | QueryEngine |
| 7 | upsert_entities(entities[]) |
Write instances (IRI reuse, properties, types) | validate → FactStore |
| 8 | upsert_relations(relations[]) |
Write relations | validate → FactStore |
| 9 | get_inference_status() |
Derived-fact freshness | Singleton metadata node |
| 10 | list_violations(focus_iri?, job_id?) |
Query SHACL violations | :ConstraintViolation |
There is no load_ontology tool. The TBox is loaded through Git, not through MCP. That is a direct consequence of “TBox authoritative source is Git” — it removes a whole tool group and a whole attack surface.
Tool contract
- Tool names, parameter names, and response shapes do not change with the backend
- New fields are additive; removals are marked
deprecated: truefor one release - Swapping Neo4j for GraphDB requires zero changes on the agent side
Boundaries worth stating
get_constraints returns the Core subset only (minCount / maxCount / datatype / class), for pre-write judgment. Responses carry scope: "core". Passing write-time validation guarantees only Core conformance — SHACL-AF / SPARQL constraint violations surface at the next offline full validation, not at write time.
list_violations defaults to the latest successful job (last_ok_job_id on the metadata node), not the latest attempt — a failed attempt wrote no violations, so the rows on the graph are from the last success. In v1 the derived layer is rebuilt wholesale, so only the latest job’s rows exist; cross-job comparison needs history retention, which is not in v1.
7. Resources & Prompts
Resources
ontology://catalog Query catalog overview (light)
ontology://playbook "How to query this ontology" — decision tree + error retry strategy
ontology://shapes shapes.ttl source (human reference)
ontology://tbox TBox.ttl source (human reference)
ontology://schema Full projection (large; human reference only, never into a prompt)
Prompts
query-playbook— read schema → pick a named query → fall back to raw Cypher → retry on errorwrite-playbook— write structure, how to fix validation failures, when derived facts update
Claude Code vs. Codex
| Carrier | Claude Code | Codex | Content |
|---|---|---|---|
| MCP tools | yes | yes | The capabilities themselves |
| MCP resources | yes | implementation-dependent | playbook, schema |
| MCP prompts | yes | implementation-dependent | the two playbooks |
| Claude Code skills | yes | no | Invocation-habit wrapper only |
Knowledge lives in MCP resources and prompts (at minimum ontology://playbook).
A skill is a thin wrapper — it must not restate the playbook, or the two will drift.
Otherwise Codex gets bare tools and nothing tells it “call search_schema first,
then list_queries, and only then query_raw”.
The skill (skills/feomcp/SKILL.md) carries the trigger conditions, the order of
operations and the Claude Code habits that are not client-agnostic. Install it:
mkdir -p .claude/skills && cp -r skills/feomcp .claude/skills/ # this project
cp -r skills/feomcp ~/.claude/skills/ # every project
8. Installation
Prerequisites
- Python 3.12+
- Docker + Docker Compose (Neo4j stack and the test suite)
- Neo4j 5.x — Community is enough to develop against; Enterprise is required if you
want read/write account separation, because RBAC (
CREATE ROLE/GRANT) is an Enterprise feature
Install
git clone https://github.com/yifanzhao2023/fan-enterprise-ontology-mcp.git
cd fan-enterprise-ontology-mcp
python -m venv .venv
.venv/bin/pip install -e ".[dev]" # Windows: .venv\Scripts\pip install -e ".[dev]"
Start Neo4j
docker compose up -d --wait
Brings up Neo4j 5.26 Community on bolt://localhost:7687 (neo4j / password).
APOC is deliberately not installed: the Cypher guard rejects every apoc.* call, so
leaving the plugin out means the database cannot run one either.
Build the schema projection
make projection
schema.projection.json is generated from TBox.ttl + shapes.ttl. Commit it —
CI fails when it is stale.
ontology/is a sample, not your ontology. It holds a runnable domain — enterprise contract management — so the server starts out of the box and CI has something to regress against. It exercises six common situations on purpose: term disambiguation, named queries, multi-hop org traversal, a legal write, an illegal write, and a security boundary. Replace all of it with your own data before you use this for real. Seeontology/README.mdfor how.
Load the sample data(Optional)
make seed
Reads ontology/abox.ttl and writes it through the same representation writer the MCP
tools use. To replace the sample ontology with your own, see
ontology/README.md.
Build the derived layer
Derived facts (:INFERRED) are produced by an offline batch job, never by a tool call:
make infer
This exports the ABox, runs ontology/rules/*.rules in a JVM that has no database
access, validates the merged graph with SHACL, and replaces the derived layer in one
transaction. Check get_inference_status() before answering from a derived fact —
stale: true means something has been written since the last run. See
ontology/README.md section 13 for the scenarios that need it.
Register with a coding agent
The server speaks MCP over stdio. The command is the console script inside your venv — use the absolute path, because the agent’s environment will not have your venv activated.
Claude Code — .mcp.json in the project root:
{
"mcpServers": {
"feomcp": {
"command": "/abs/path/to/.venv/bin/feomcp",
"args": [],
"env": {
"FEOMCP_ONTOLOGY_DIR": "/abs/path/to/ontology",
"FEOMCP_NEO4J_URI": "bolt://localhost:7687",
"FEOMCP_NEO4J_USER": "reader",
"FEOMCP_NEO4J_PASSWORD": "…",
"FEOMCP_NEO4J_WRITE_USER": "writer",
"FEOMCP_NEO4J_WRITE_PASSWORD": "…"
}
}
}
}
or claude mcp add feomcp -- /abs/path/to/.venv/bin/feomcp.
Codex — ~/.codex/config.toml:
[mcp_servers.feomcp]
command = "/abs/path/to/.venv/bin/feomcp"
[mcp_servers.feomcp.env]
FEOMCP_ONTOLOGY_DIR = "/abs/path/to/ontology"
FEOMCP_NEO4J_URI = "bolt://localhost:7687"
FEOMCP_NEO4J_USER = "reader"
FEOMCP_NEO4J_PASSWORD = "…"
Any other MCP client — stdio transport, command feomcp (equivalently
python -m feomcp.server).
The server refuses to start when the query and write accounts are the same account. Give the query account
MATCH-only privileges, or setFEOMCP_ALLOW_SHARED_ACCOUNT=1for development.
Verify
make demo
Starts Neo4j, regenerates the projection, loads the sample ABox and runs every competency question against its expected result.
9. Configuration
Environment variables. No config framework.
| Variable | Default | Purpose |
|---|---|---|
FEOMCP_STORE |
neo4j |
Storage adapter |
FEOMCP_NEO4J_URI |
bolt://localhost:7687 |
Neo4j bolt URI |
FEOMCP_NEO4J_USER |
neo4j |
Read-only account (query tools) |
FEOMCP_NEO4J_PASSWORD |
(empty) | |
FEOMCP_NEO4J_WRITE_USER |
same as FEOMCP_NEO4J_USER |
Read-write account (write tools) |
FEOMCP_NEO4J_WRITE_PASSWORD |
same as FEOMCP_NEO4J_PASSWORD |
|
FEOMCP_ONTOLOGY_DIR |
ontology/ |
TBox, shapes, queries, manifest, projection, ABox |
FEOMCP_QUERY_TIMEOUT_MS |
10000 |
Per-query timeout |
FEOMCP_MAX_ROWS |
50 |
Result row cap (also stops reading early) |
FEOMCP_MAX_QUERY_LENGTH |
8192 |
Query string length cap |
FEOMCP_MAX_BATCH |
500 |
Single write batch cap |
FEOMCP_REDACT_FIELDS |
(empty) | Comma-separated field names masked at read time |
FEOMCP_ALLOW_SHARED_ACCOUNT |
0 |
1 permits query and write to share one account (development only) |
Privilege separation is native database accounts, not code branches. The server keeps two driver sessions and routes by tool group: query tools (1–6, 9, 10) use the read-only account, write tools (7, 8) use the read-write account.
build_server refuses to start when both point at the same account unless
FEOMCP_ALLOW_SHARED_ACCOUNT=1 is set.
Defaults assume the docker compose stack. Production must set the account variables.
10. Quick Start
# one shot: reset the volume, start Neo4j, build the projection, seed the
# sample ABox, run the offline inference chain, and execute every CQ
make demo
That runs the whole path end to end. To drive it step by step instead:
docker compose up -d --wait # start Neo4j
make seed # projection + load the sample ABox
make infer # build the derived layer
make e2e # run every CQ against its sample_result
Then point an agent at the server (see Installation) and ask a business question. A good first one:
Using the ontology, which contracts does customerA have that are over 1,000,000?
The agent should call search_schema / list_queries first, then
run_query("contracts_over_amount_by_customer", {...}) — not write raw Cypher.
To see the tools without an agent:
.venv/bin/python -m feomcp.server # starts the MCP stdio server
This is a sample domain. ontology/ models enterprise contract management purely
so the stack runs out of the box — see ontology/README.md for
how to replace it with your own ontology.
11. Security Model
-
Account separation — read-only for query tools, read-write for write tools, no DB credentials in the JVM.
-
Cypher injection —
query_rawparameters are always parameterized; string concatenation into a query is forbidden. The guard statically rejectsCREATE/MERGE/DELETE/DETACH/SET/REMOVE/DROP/LOAD CSV/dbms.*/ anyCALLoutside the whitelist. APOC is disabled entirely —apoc.cypher.runand relatives execute arbitrary Cypher strings and are code-execution escape hatches, not utilities. -
Whitelist is default-deny and near-empty: built-in read-only functions only. Schema introspection goes through
get_schema(), so nothing forces the whitelist open. Any procedure added later goes into an explicit config list, off by default. -
Resource caps — query timeout, row cap, query length cap, write batch cap.
-
Audit log —
tool/ parameter digest /principal(caller identity: agent session ID or service account) /timestamp/duration/row_count/ok. Full result bodies are not logged. -
Field redaction at read time —
FEOMCP_REDACT_FIELDSmasks values on query return. Storage keeps complete data; writes do not redact. Redaction covers violation details too (list_violationsmessages often embed field values). v1 is global config, not per-principal.# ponytail: field redaction + account separation + audit only; triple-level RBAC/ABAC # deferred until compliance requirements are concreteFull RBAC over triples is a large undertaking whose shape (row-level / field-level / attribute-based) must be driven by the business. v1 ships what is deployable and leaves the seam open rather than a fake RBAC.
-
SHACL is not authorization. SHACL governs data quality, not access control. Do not rely on it for permissions.
-
Agents cannot trigger expensive operations. There is no
rebuild_inferredtool. Inference is triggered by CI or cron only.
Failure handling
| Failure | Handling |
|---|---|
| Neo4j unavailable | BACKEND_UNAVAILABLE; no automatic retry storm |
| Projection missing or invalid | Refuse to start (validated at boot) |
| pySHACL throws | VALIDATION_FAILED with a truncated raw exception summary |
| JVM unavailable / timeout / inference fails | Do not apply; previous :INFERRED preserved; job metadata records failure |
abox.ttl export fails |
Same — do not apply |
| Apply transaction fails | Single transaction rolls back; previous derived data intact |
| Dirty git working tree | onto infer refuses; --allow-dirty runs and marks the metadata |
“On failure, do not apply” is the core invariant. Old derived data beats no derived data, and beats a half-built one. Any step failing leaves the graph at the last successful job’s state.
Three invariants
- A write either fully commits or fully disappears (Neo4j single transaction).
:INFERRED/:ConstraintViolationis always exactly one complete job’s result — never a mixture.- The JVM process never holds database write access.
12. Development
Repository layout
src/feomcp/ the MCP server and the offline chain
ports.py QueryEngine + FactStore protocols
neo4j_store.py the only adapter: driver, guard call site, representation writer
schema.py reads schema.projection.json
catalog.py named queries (the CQ catalog)
validate.py SHACL Core at write time, full SHACL offline, TBox profile gate
guard.py the Cypher trust boundary
server.py tool surface — the only exit
cli.py `onto project` / `onto seed` / `onto infer`
jvm/ rules-runner: Turtle in, delta out. No DB, no network
ontology/ sample domain (replace it — see ontology/README.md)
rules/*.rules Jena rules; arithmetic business conditions live here
tests/ unit / integration / e2e
Local development workflow
python3.12 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
make rules-runner # build jvm/target/rules-runner.jar (needs JDK 17+ and Maven)
make unit # no dependencies
make integration # testcontainers, needs a running Docker daemon
make demo # down -v + up + seed + infer + e2e against docker-compose
make integration and make demo need Docker. make unit does not — it is the
fast loop.
Module boundaries (design summary)
Only two Python ports earn their interface — storage and query — because both have a planned second implementation. SHACL validation, projection reading, the query catalog, and the representation writer are concrete: a second implementation is not planned, and wrapping them would be tax paid to a hypothetical.
Inference is a process boundary, not a Python interface. Its contract is file exchange (Turtle in → inferred.ttl out). Swapping Jena for ELK means replacing rules-runner; the Python orchestration layer does not change.
13. Testing & Acceptance
Test layers
| Layer | Scope | Depends on |
|---|---|---|
| Unit | guard (table-driven injection samples), schema / catalog parsing, validate Core judgments, cli apply logic against fake JVM output |
nothing |
| Integration | neo4j_store: upsert atomicity, representation writer, materialize_rdf round-trip, replace_derived rebuild |
testcontainers (Neo4j) |
| End-to-end | make demo: load sample → onto infer → all CQs → assert sample_result |
docker-compose |
| CQ regression | Every manifest entry runs sample_params against sample_result |
same |
CI gates (every PR)
| Gate | Catches |
|---|---|
onto project then git diff --exit-code |
Projection drifting from Turtle |
| TBox profile check | OWL constructs beyond RL |
| SHACL validation of sample ABox | Dirty sample data |
onto infer runs |
Broken rules |
All CQs vs sample_result |
Ontology changes breaking queries |
manifest uses ⊆ projection |
Queries referencing deleted classes / properties |
| Unit + integration tests | Code regressions |
make demo full chain |
Integration breaks |
TBox profile check: feomcp.validate.banned_owl_constructs runs before the JVM is launched and rejects the five constructors the modeling contract forbids (owl:unionOf / owl:complementOf / owl:oneOf / owl:hasValue / owl:disjointWith). It is a blacklist lint and is named as incomplete in the code. The spec’s preferred authoritative check was an OWL API OWL2RLProfile; Jena has no such class, so the blacklist is the implementation.
Error paths that must be tested
- Every guard rule has at least one sample (including escape hatches like
apoc.cypher.run) VALIDATION_FAILEDis field-level (shape / path / message / focus)- JVM failure does not apply — previous
:INFERREDintact - Batch atomicity: half a batch invalid → nothing lands
- Timeout / row cap triggers
- Projection missing or invalid → refuse to start
- Dirty git tree →
onto inferrefuses
Verified behaviours
The spikes that were open at design time are resolved; each is now pinned by a test.
| Behaviour | What holds |
|---|---|
| ABox round-trip | materialize_rdf re-emits every entity, type, property and relation that was written (zero diff) |
| URI unique key | the uri property is the entity unique key; repeated writes are idempotent; no duplicate entities |
| Version compatibility | Neo4j 5.x stable for the read/write sessions and the derived-layer rebuild |
The one open risk is large-instance export memory (see Known risk); the sample is 18 entities, so it is untested at production scale.
Metrics (POC thresholds, fixed)
| Metric | Threshold |
|---|---|
| Named-query path success rate | ≥ 99% |
| Raw Cypher path syntax pass rate | ≥ 85% (after one retry) |
| Raw Cypher execution success rate | ≥ 95% |
| Answer correctness | ≥ 90% |
| CQ coverage | 100% on sample set, ≥ 90% on real ontology |
| SHACL violation rate | < 1% |
| Query P95 | < 500 ms |
| Write validation P95 | < 200 ms |
| Schema tokens injected per call | < 2000 |
The two query paths are measured separately so the value of the query catalog is visible. If the named-query path drops below 95%, the fix is more CQs — not tuning raw Cypher.
14. Limitations
- No modeling UI or domain modeling DSL. Use Protégé.
- No ontology alignment or knowledge extraction (planned).
- Inference is offline and batch; derived facts are stale between runs by design.
- TBox is restricted to the OWL 2 RL subset.
- Query results use a JSON-LD-aligned
@context, not strict JSON-LD. RDF interchange only happens over the file chain. - Field redaction is global config, not per-principal. Triple-level RBAC is not in v1.
- JVM required for
onto infer.
15. Roadmap
| Item | Notes |
|---|---|
| Ontology alignment & knowledge extraction | New capability module; new tools + a package, reusing FactStore.upsert + validate_core |
| GraphDB backend | New adapter implementing QueryEngine / FactStore; tool schema unchanged; adds submit(lang="sparql") |
| OWL 2 EL + ELK | When the TBox outgrows RL (large class hierarchies, equivalence, existential restrictions) |
| Business-rule sync path (write-time rule application) | Small hand-maintained ruleset for derived facts needed immediately after a write |
| Triple-level RBAC / ABAC | Once compliance requirements are concrete |
| OBDA / Ontop for relational sources | If data volume makes full extraction unattractive |
License
Licensed under the Apache License, Version 2.0.
Install
This server does not publish a one-line install command.
Open the repository installation guideConfiguration
{
"mcpServers": {
"feomcp": {
"command": "/abs/path/to/.venv/bin/feomcp",
"args": [],
"env": {
"FEOMCP_ONTOLOGY_DIR": "/abs/path/to/ontology",
"FEOMCP_NEO4J_URI": "bolt://localhost:7687",
"FEOMCP_NEO4J_USER": "reader",
"FEOMCP_NEO4J_PASSWORD": "…",
"FEOMCP_NEO4J_WRITE_USER": "writer",
"FEOMCP_NEO4J_WRITE_PASSWORD": "…"
}
}
}
}