Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions servers/mcp-neo4j-data-modeling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Changed

### Added
* Add import and export methods to `DataModel` for turtle OWL strings
* Add MCP tools for loading and exporting turtle OWL files

## v0.5.1

Expand Down
1 change: 1 addition & 0 deletions servers/mcp-neo4j-data-modeling/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"fastmcp>=2.0.0",
"pydantic>=2.10.1",
"starlette>=0.47.0",
"rdflib>=7.0.0",
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any

from pydantic import BaseModel, Field, ValidationInfo, field_validator
from rdflib import Graph, Namespace, RDF, RDFS, OWL, XSD, Literal, URIRef

NODE_COLOR_PALETTE = [
("#e3f2fd", "#1976d2"), # Light Blue / Blue
Expand Down Expand Up @@ -551,6 +552,195 @@ def to_arrows_dict(self) -> dict[str, Any]:
def to_arrows_json_str(self) -> str:
"Convert the data model to an Arrows Data Model JSON string."
return json.dumps(self.to_arrows_dict(), indent=2)

def to_owl_turtle_str(self) -> str:
"""Convert the data model to an OWL Turtle string.

This method creates an OWL ontology from the Neo4j data model:
- Node labels become OWL Classes
- Node properties become OWL DatatypeProperties with the node class as domain
- Relationship types become OWL ObjectProperties with start/end nodes as domain/range
- Relationship properties become OWL DatatypeProperties with the relationship as domain
"""
# Create a new RDF graph
g = Graph()

# Define namespaces
# Use a generic namespace for the ontology
base_ns = Namespace("http://voc.neo4j.com/datamodel#")
g.bind("", base_ns)
g.bind("owl", OWL)
g.bind("rdfs", RDFS)
g.bind("xsd", XSD)

# Create the ontology declaration
ontology_uri = URIRef("http://voc.neo4j.com/datamodel")
g.add((ontology_uri, RDF.type, OWL.Ontology))

# Map Neo4j types to XSD types
type_mapping = {
"STRING": XSD.string,
"INTEGER": XSD.integer,
"FLOAT": XSD.float,
"BOOLEAN": XSD.boolean,
"DATE": XSD.date,
"DATETIME": XSD.dateTime,
"TIME": XSD.time,
"DURATION": XSD.duration,
"LONG": XSD.long,
"DOUBLE": XSD.double,
}

# Process nodes -> OWL Classes
for node in self.nodes:
class_uri = base_ns[node.label]
g.add((class_uri, RDF.type, OWL.Class))

# Add key property as a datatype property
if node.key_property:
prop_uri = base_ns[node.key_property.name]
g.add((prop_uri, RDF.type, OWL.DatatypeProperty))
g.add((prop_uri, RDFS.domain, class_uri))
xsd_type = type_mapping.get(node.key_property.type.upper(), XSD.string)
g.add((prop_uri, RDFS.range, xsd_type))

# Add other properties as datatype properties
for prop in node.properties:
prop_uri = base_ns[prop.name]
g.add((prop_uri, RDF.type, OWL.DatatypeProperty))
g.add((prop_uri, RDFS.domain, class_uri))
xsd_type = type_mapping.get(prop.type.upper(), XSD.string)
g.add((prop_uri, RDFS.range, xsd_type))

# Process relationships -> OWL ObjectProperties
for rel in self.relationships:
rel_uri = base_ns[rel.type]
g.add((rel_uri, RDF.type, OWL.ObjectProperty))
g.add((rel_uri, RDFS.domain, base_ns[rel.start_node_label]))
g.add((rel_uri, RDFS.range, base_ns[rel.end_node_label]))

# If relationship has properties, create datatype properties
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lines 622-635 should be removed
In OWL only instances of classes can have properties. Instances of relationships can't.
If we state that the domain of property x is Y (y being a relationship/objectproperty), OWL will interpret it as "Y is a class".
This effectively means that a mermaid-to-OWL translation is potentially lossy because of the limited expressivity of RDF.

if rel.key_property:
prop_uri = base_ns[f"{rel.type}_{rel.key_property.name}"]
g.add((prop_uri, RDF.type, OWL.DatatypeProperty))
g.add((prop_uri, RDFS.domain, rel_uri))
xsd_type = type_mapping.get(rel.key_property.type.upper(), XSD.string)
g.add((prop_uri, RDFS.range, xsd_type))

for prop in rel.properties:
prop_uri = base_ns[f"{rel.type}_{prop.name}"]
g.add((prop_uri, RDF.type, OWL.DatatypeProperty))
g.add((prop_uri, RDFS.domain, rel_uri))
xsd_type = type_mapping.get(prop.type.upper(), XSD.string)
g.add((prop_uri, RDFS.range, xsd_type))

# Serialize to Turtle format
return g.serialize(format="turtle")

@classmethod
def from_owl_turtle_str(cls, owl_turtle_str: str) -> "DataModel":
"""Convert an OWL Turtle string to a Neo4j Data Model.

This method parses an OWL ontology and creates a Neo4j data model:
- OWL Classes become Node labels
- OWL DatatypeProperties with Class domains become Node properties
- OWL ObjectProperties become Relationships
- Property domains and ranges are used to infer Node labels and types
"""
# Parse the Turtle string
g = Graph()
g.parse(data=owl_turtle_str, format="turtle")

# Map XSD types back to Neo4j types
xsd_to_neo4j = {
str(XSD.string): "STRING",
str(XSD.integer): "INTEGER",
str(XSD.float): "FLOAT",
str(XSD.boolean): "BOOLEAN",
str(XSD.date): "DATE",
str(XSD.dateTime): "DATETIME",
str(XSD.time): "TIME",
str(XSD.duration): "DURATION",
str(XSD.long): "LONG",
str(XSD.double): "DOUBLE",
}

# Extract OWL Classes -> Nodes
classes = set()
for s in g.subjects(RDF.type, OWL.Class):
classes.add(str(s).split("#")[-1].split("/")[-1])

# Extract DatatypeProperties
datatype_props = {}
for prop in g.subjects(RDF.type, OWL.DatatypeProperty):
prop_name = str(prop).split("#")[-1].split("/")[-1]
domains = list(g.objects(prop, RDFS.domain))
ranges = list(g.objects(prop, RDFS.range))

domain_name = str(domains[0]).split("#")[-1].split("/")[-1] if domains else None
range_type = xsd_to_neo4j.get(str(ranges[0]), "STRING") if ranges else "STRING"

if domain_name:
if domain_name not in datatype_props:
datatype_props[domain_name] = []
datatype_props[domain_name].append({
"name": prop_name,
"type": range_type
})

# Extract ObjectProperties -> Relationships
object_props = []
for prop in g.subjects(RDF.type, OWL.ObjectProperty):
prop_name = str(prop).split("#")[-1].split("/")[-1]
domains = list(g.objects(prop, RDFS.domain))
ranges = list(g.objects(prop, RDFS.range))

if domains and ranges:
domain_name = str(domains[0]).split("#")[-1].split("/")[-1]
range_name = str(ranges[0]).split("#")[-1].split("/")[-1]

object_props.append({
"type": prop_name,
"start_node_label": domain_name,
"end_node_label": range_name
})

# Create Nodes
nodes = []
for class_name in classes:
props_for_class = datatype_props.get(class_name, [])

# Use the first property as key property, or create a default one
if props_for_class:
key_prop = Property(
name=props_for_class[0]["name"],
type=props_for_class[0]["type"]
)
other_props = [
Property(name=p["name"], type=p["type"])
for p in props_for_class[1:]
]
else:
# Create a default key property
key_prop = Property(name=f"{class_name.lower()}Id", type="STRING")
other_props = []

nodes.append(Node(
label=class_name,
key_property=key_prop,
properties=other_props
))

# Create Relationships
relationships = []
for obj_prop in object_props:
relationships.append(Relationship(
type=obj_prop["type"],
start_node_label=obj_prop["start_node_label"],
end_node_label=obj_prop["end_node_label"]
))

return cls(nodes=nodes, relationships=relationships)

def get_node_cypher_ingest_query_for_many_records(self, node_label: str) -> str:
"Generate a Cypher query to ingest a list of Node records into a Neo4j database."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,18 @@ def list_example_data_models() -> dict[str, Any]:
"total_examples": len(examples),
"usage": "Use the get_example_data_model tool with any of the example names above to get a specific data model",
}

@mcp.tool(name=namespace_prefix + "load_from_owl_turtle")
def load_from_owl_turtle(owl_turtle_str: str) -> DataModel:
"""Load a data model from an OWL Turtle string. Returns a DataModel object."""
logger.info("Loading a data model from an OWL Turtle string.")
return DataModel.from_owl_turtle_str(owl_turtle_str)

@mcp.tool(name=namespace_prefix + "export_to_owl_turtle")
def export_to_owl_turtle(data_model: DataModel) -> str:
"""Export a data model to an OWL Turtle string. Returns a string."""
logger.info("Exporting a data model to an OWL Turtle string.")
return data_model.to_owl_turtle_str()

@mcp.prompt(title="Create New Data Model")
def create_new_data_model(
Expand Down
113 changes: 113 additions & 0 deletions servers/mcp-neo4j-data-modeling/tests/resources/blueplaques.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
@prefix : <http://voc.neo4j.com/blueplaques#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<http://voc.neo4j.com/blueplaques> a owl:Ontology .

:COMPOSED a owl:ObjectProperty ;
rdfs:domain :Person ;
rdfs:range :MusicalComposition .

:HONORED_BY a owl:ObjectProperty ;
rdfs:domain :Person ;
rdfs:range :Plaque .

:LOCATED_AT a owl:ObjectProperty ;
rdfs:domain :Plaque ;
rdfs:range :Address .

:addressId a owl:DatatypeProperty ;
rdfs:domain :Address ;
rdfs:range xsd:string .

:area a owl:DatatypeProperty ;
rdfs:domain :Address ;
rdfs:range xsd:string .

:birthYear a owl:DatatypeProperty ;
rdfs:domain :Person ;
rdfs:range xsd:integer .

:borough a owl:DatatypeProperty ;
rdfs:domain :Address ;
rdfs:range xsd:string .

:compositionId a owl:DatatypeProperty ;
rdfs:domain :MusicalComposition ;
rdfs:range xsd:string .

:deathYear a owl:DatatypeProperty ;
rdfs:domain :Person ;
rdfs:range xsd:integer .

:erectionYear a owl:DatatypeProperty ;
rdfs:domain :Plaque ;
rdfs:range xsd:integer .

:genre a owl:DatatypeProperty ;
rdfs:domain :MusicalComposition ;
rdfs:range xsd:string .

:inscription a owl:DatatypeProperty ;
rdfs:domain :Plaque ;
rdfs:range xsd:string .

:material a owl:DatatypeProperty ;
rdfs:domain :Plaque ;
rdfs:range xsd:string .

:name a owl:DatatypeProperty ;
rdfs:domain :Organization,
:Person ;
rdfs:range xsd:string .

:nationality a owl:DatatypeProperty ;
rdfs:domain :Person ;
rdfs:range xsd:string .

:organizationType a owl:DatatypeProperty ;
rdfs:domain :Organization ;
rdfs:range xsd:string .

:personId a owl:DatatypeProperty ;
rdfs:domain :Person ;
rdfs:range xsd:string .

:plaqueId a owl:DatatypeProperty ;
rdfs:domain :Plaque ;
rdfs:range xsd:string .

:postcode a owl:DatatypeProperty ;
rdfs:domain :Address ;
rdfs:range xsd:string .

:profession a owl:DatatypeProperty ;
rdfs:domain :Person ;
rdfs:range xsd:string .

:professionCategory a owl:DatatypeProperty ;
rdfs:domain :Person ;
rdfs:range xsd:string .

:streetAddress a owl:DatatypeProperty ;
rdfs:domain :Address ;
rdfs:range xsd:string .

:title a owl:DatatypeProperty ;
rdfs:domain :MusicalComposition ;
rdfs:range xsd:string .

:yearComposed a owl:DatatypeProperty ;
rdfs:domain :MusicalComposition ;
rdfs:range xsd:integer .

:Organization a owl:Class .

:MusicalComposition a owl:Class .

:Address a owl:Class .

:Plaque a owl:Class .

:Person a owl:Class .
Loading