Skip to content

Commit fc031f3

Browse files
authored
Data modeling - allow JSON object and String as tool input (#231)
* first pass - works with middleware and union tool arg types * replace middleware with utils fn * update json parser docstring and error * update json parsing fn name * formatting
1 parent 091b709 commit fc031f3

File tree

6 files changed

+197
-72
lines changed

6 files changed

+197
-72
lines changed

servers/mcp-neo4j-data-modeling/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
* Fix bug in Dockerfile where build would fail due to `LABEL` statement coming before `FROM` statement
55

66
### Changed
7+
* Tools that received Pydantic objects as arguments now also accept JSON strings as input. This is for client applications that send JSON strings instead of objects for tool arguments. This is a workaround for client applications that don't adhere to the defined tool schemas and will be removed in the future once it is not needed.
78

89
### Added
10+
* Added JSON string parsing utils function. This is for client applications that send JSON strings instead of objects for tool arguments. This is a workaround for client applications that don't adhere to the defined tool schemas and will be removed in the future once it is not needed.
911

1012
## v0.6.1
1113

servers/mcp-neo4j-data-modeling/src/mcp_neo4j_data_modeling/data_model.py

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any
44

55
from pydantic import BaseModel, Field, ValidationInfo, field_validator
6-
from rdflib import Graph, Namespace, RDF, RDFS, OWL, XSD, Literal, URIRef
6+
from rdflib import OWL, RDF, RDFS, XSD, Graph, Namespace, URIRef
77

88
NODE_COLOR_PALETTE = [
99
("#e3f2fd", "#1976d2"), # Light Blue / Blue
@@ -552,7 +552,7 @@ def to_arrows_dict(self) -> dict[str, Any]:
552552
def to_arrows_json_str(self) -> str:
553553
"Convert the data model to an Arrows Data Model JSON string."
554554
return json.dumps(self.to_arrows_dict(), indent=2)
555-
555+
556556
def to_owl_turtle_str(self) -> str:
557557
"""
558558
Convert the data model to an OWL Turtle string.
@@ -622,7 +622,7 @@ def to_owl_turtle_str(self) -> str:
622622
g.add((rel_uri, RDFS.domain, base_ns[rel.start_node_label]))
623623
g.add((rel_uri, RDFS.range, base_ns[rel.end_node_label]))
624624

625-
# relationships don't have properties in the OWL format.
625+
# relationships don't have properties in the OWL format.
626626
# This means translation to OWL is lossy.
627627

628628
# Serialize to Turtle format
@@ -671,16 +671,19 @@ def from_owl_turtle_str(cls, owl_turtle_str: str) -> "DataModel":
671671
domains = list(g.objects(prop, RDFS.domain))
672672
ranges = list(g.objects(prop, RDFS.range))
673673

674-
domain_name = str(domains[0]).split("#")[-1].split("/")[-1] if domains else None
675-
range_type = xsd_to_neo4j.get(str(ranges[0]), "STRING") if ranges else "STRING"
674+
domain_name = (
675+
str(domains[0]).split("#")[-1].split("/")[-1] if domains else None
676+
)
677+
range_type = (
678+
xsd_to_neo4j.get(str(ranges[0]), "STRING") if ranges else "STRING"
679+
)
676680

677681
if domain_name:
678682
if domain_name not in datatype_props:
679683
datatype_props[domain_name] = []
680-
datatype_props[domain_name].append({
681-
"name": prop_name,
682-
"type": range_type
683-
})
684+
datatype_props[domain_name].append(
685+
{"name": prop_name, "type": range_type}
686+
)
684687

685688
# Extract ObjectProperties -> Relationships
686689
object_props = []
@@ -693,11 +696,13 @@ def from_owl_turtle_str(cls, owl_turtle_str: str) -> "DataModel":
693696
domain_name = str(domains[0]).split("#")[-1].split("/")[-1]
694697
range_name = str(ranges[0]).split("#")[-1].split("/")[-1]
695698

696-
object_props.append({
697-
"type": prop_name,
698-
"start_node_label": domain_name,
699-
"end_node_label": range_name
700-
})
699+
object_props.append(
700+
{
701+
"type": prop_name,
702+
"start_node_label": domain_name,
703+
"end_node_label": range_name,
704+
}
705+
)
701706

702707
# Create Nodes
703708
nodes = []
@@ -707,8 +712,7 @@ def from_owl_turtle_str(cls, owl_turtle_str: str) -> "DataModel":
707712
# Use the first property as key property, or create a default one
708713
if props_for_class:
709714
key_prop = Property(
710-
name=props_for_class[0]["name"],
711-
type=props_for_class[0]["type"]
715+
name=props_for_class[0]["name"], type=props_for_class[0]["type"]
712716
)
713717
other_props = [
714718
Property(name=p["name"], type=p["type"])
@@ -719,22 +723,22 @@ def from_owl_turtle_str(cls, owl_turtle_str: str) -> "DataModel":
719723
key_prop = Property(name=f"{class_name.lower()}Id", type="STRING")
720724
other_props = []
721725

722-
nodes.append(Node(
723-
label=class_name,
724-
key_property=key_prop,
725-
properties=other_props
726-
))
726+
nodes.append(
727+
Node(label=class_name, key_property=key_prop, properties=other_props)
728+
)
727729

728730
# Create Relationships
729731
relationships = []
730732
for obj_prop in object_props:
731-
relationships.append(Relationship(
732-
type=obj_prop["type"],
733-
start_node_label=obj_prop["start_node_label"],
734-
end_node_label=obj_prop["end_node_label"]
735-
))
733+
relationships.append(
734+
Relationship(
735+
type=obj_prop["type"],
736+
start_node_label=obj_prop["start_node_label"],
737+
end_node_label=obj_prop["end_node_label"],
738+
)
739+
)
736740

737-
return cls(nodes=nodes, relationships=relationships)
741+
return cls(nodes=nodes, relationships=relationships)
738742

739743
def get_node_cypher_ingest_query_for_many_records(self, node_label: str) -> str:
740744
"Generate a Cypher query to ingest a list of Node records into a Neo4j database."

servers/mcp-neo4j-data-modeling/src/mcp_neo4j_data_modeling/server.py

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import json
22
import logging
3-
from typing import Any, Literal
3+
from typing import Any, Literal, Union
44

55
from fastmcp.server import FastMCP
66
from pydantic import Field, ValidationError
77
from starlette.middleware import Middleware
88
from starlette.middleware.cors import CORSMiddleware
99
from starlette.middleware.trustedhost import TrustedHostMiddleware
10-
from .utils import format_namespace
1110

1211
from .data_model import (
1312
DataModel,
@@ -26,6 +25,7 @@
2625
SOFTWARE_DEPENDENCY_MODEL,
2726
SUPPLY_CHAIN_MODEL,
2827
)
28+
from .utils import format_namespace, parse_dict_from_json_input
2929

3030
logger = logging.getLogger("mcp_neo4j_data_modeling")
3131

@@ -113,12 +113,19 @@ def example_health_insurance_fraud_model() -> str:
113113

114114
@mcp.tool(name=namespace_prefix + "validate_node")
115115
def validate_node(
116-
node: Node, return_validated: bool = False
116+
node: Union[str, Node], return_validated: bool = False
117117
) -> bool | dict[str, Any]:
118-
"Validate a single node. Returns True if the node is valid, otherwise raises a ValueError. If return_validated is True, returns the validated node."
118+
"""
119+
Validate a single node.
120+
Returns True if the node is valid, otherwise raises a ValueError.
121+
If return_validated is True, returns the validated node.
122+
Accepts either a Node object or a JSON string of the Node object.
123+
"""
119124
logger.info("Validating a single node.")
120125
try:
121-
validated_node = Node.model_validate(node, strict=True)
126+
# Parse the node argument (handles both JSON string and dict)
127+
node_dict = parse_dict_from_json_input(node)
128+
validated_node = Node.model_validate(node_dict, strict=True)
122129
logger.info("Node validated successfully")
123130
if return_validated:
124131
return validated_node
@@ -130,13 +137,20 @@ def validate_node(
130137

131138
@mcp.tool(name=namespace_prefix + "validate_relationship")
132139
def validate_relationship(
133-
relationship: Relationship, return_validated: bool = False
140+
relationship: Union[str, Relationship], return_validated: bool = False
134141
) -> bool | dict[str, Any]:
135-
"Validate a single relationship. Returns True if the relationship is valid, otherwise raises a ValueError. If return_validated is True, returns the validated relationship."
142+
"""
143+
Validate a single relationship.
144+
Returns True if the relationship is valid, otherwise raises a ValueError.
145+
If return_validated is True, returns the validated relationship.
146+
Accepts either a Relationship object or a JSON string of the Relationship object.
147+
"""
136148
logger.info("Validating a single relationship.")
137149
try:
150+
# Parse the relationship argument (handles both JSON string and dict)
151+
relationship_dict = parse_dict_from_json_input(relationship)
138152
validated_relationship = Relationship.model_validate(
139-
relationship, strict=True
153+
relationship_dict, strict=True
140154
)
141155
logger.info("Relationship validated successfully")
142156
if return_validated:
@@ -149,15 +163,22 @@ def validate_relationship(
149163

150164
@mcp.tool(name=namespace_prefix + "validate_data_model")
151165
def validate_data_model(
152-
data_model: DataModel, return_validated: bool = False
166+
data_model: Union[str, DataModel], return_validated: bool = False
153167
) -> bool | dict[str, Any]:
154-
"Validate the entire data model. Returns True if the data model is valid, otherwise raises a ValueError. If return_validated is True, returns the validated data model."
168+
"""
169+
Validate the entire data model.
170+
Returns True if the data model is valid, otherwise raises a ValueError.
171+
If return_validated is True, returns the validated data model.
172+
Accepts either a DataModel object or a JSON string of the DataModel object.
173+
"""
155174
logger.info("Validating the entire data model.")
156175
try:
157-
DataModel.model_validate(data_model, strict=True)
176+
# Parse the data_model argument (handles both JSON string and dict)
177+
data_model_dict = parse_dict_from_json_input(data_model)
178+
validated_model = DataModel.model_validate(data_model_dict, strict=True)
158179
logger.info("Data model validated successfully")
159180
if return_validated:
160-
return data_model
181+
return validated_model
161182
else:
162183
return True
163184
except ValidationError as e:
@@ -171,40 +192,58 @@ def load_from_arrows_json(arrows_data_model_dict: dict[str, Any]) -> DataModel:
171192
return DataModel.from_arrows(arrows_data_model_dict)
172193

173194
@mcp.tool(name=namespace_prefix + "export_to_arrows_json")
174-
def export_to_arrows_json(data_model: DataModel) -> str:
175-
"Export the data model to the Arrows web application format. Returns a JSON string. This should be presented to the user as an artifact if possible."
195+
def export_to_arrows_json(data_model: Union[str, DataModel]) -> str:
196+
"""
197+
Export the data model to the Arrows web application format.
198+
Returns a JSON string. This should be presented to the user as an artifact if possible.
199+
Accepts either a DataModel object or a JSON string of the DataModel object.
200+
"""
176201
logger.info("Exporting the data model to the Arrows web application format.")
177-
return data_model.to_arrows_json_str()
202+
# Parse the data_model argument (handles both JSON string and dict)
203+
data_model_dict = parse_dict_from_json_input(data_model)
204+
data_model_obj = DataModel.model_validate(data_model_dict)
205+
return data_model_obj.to_arrows_json_str()
178206

179207
@mcp.tool(name=namespace_prefix + "get_mermaid_config_str")
180-
def get_mermaid_config_str(data_model: DataModel) -> str:
181-
"Get the Mermaid configuration string for the data model. This may be visualized in Claude Desktop and other applications with Mermaid support."
208+
def get_mermaid_config_str(data_model: Union[str, DataModel]) -> str:
209+
"""
210+
Get the Mermaid configuration string for the data model.
211+
This may be visualized in Claude Desktop and other applications with Mermaid support.
212+
Accepts either a DataModel object or a JSON string of the DataModel object.
213+
"""
182214
logger.info("Getting the Mermaid configuration string for the data model.")
183215
try:
184-
dm_validated = DataModel.model_validate(data_model, strict=True)
216+
# Parse the data_model argument (handles both JSON string and dict)
217+
data_model_dict = parse_dict_from_json_input(data_model)
218+
dm_validated = DataModel.model_validate(data_model_dict, strict=True)
185219
except ValidationError as e:
186220
logger.error(f"Validation error: {e}")
187221
raise ValueError(f"Validation error: {e}")
188222
return dm_validated.get_mermaid_config_str()
189223

190224
@mcp.tool(name=namespace_prefix + "get_node_cypher_ingest_query")
191225
def get_node_cypher_ingest_query(
192-
node: Node = Field(description="The node to get the Cypher query for."),
226+
node: Union[str, Node] = Field(
227+
description="The node to get the Cypher query for. Accepts either a Node object or a JSON string of the Node object."
228+
),
193229
) -> str:
194230
"""
195231
Get the Cypher query to ingest a list of Node records into a Neo4j database.
196232
This should be used to ingest data into a Neo4j database.
197233
This is a parameterized Cypher query that takes a list of records as input to the $records parameter.
198234
"""
235+
# Parse the node argument (handles both JSON string and dict)
236+
node_dict = parse_dict_from_json_input(node)
237+
node_obj = Node.model_validate(node_dict)
199238
logger.info(
200-
f"Getting the Cypher query to ingest a list of Node records into a Neo4j database for node {node.label}."
239+
f"Getting the Cypher query to ingest a list of Node records into a Neo4j database for node {node_obj.label}."
201240
)
202-
return node.get_cypher_ingest_query_for_many_records()
241+
return node_obj.get_cypher_ingest_query_for_many_records()
203242

204243
@mcp.tool(name=namespace_prefix + "get_relationship_cypher_ingest_query")
205244
def get_relationship_cypher_ingest_query(
206-
data_model: DataModel = Field(
207-
description="The data model snippet that contains the relationship, start node and end node."
245+
data_model: Union[str, DataModel] = Field(
246+
description="The data model snippet that contains the relationship, start node and end node. Accepts either a DataModel object or a JSON string of the DataModel object."
208247
),
209248
relationship_type: str = Field(
210249
description="The type of the relationship to get the Cypher query for."
@@ -222,22 +261,32 @@ def get_relationship_cypher_ingest_query(
222261
This is a parameterized Cypher query that takes a list of records as input to the $records parameter.
223262
The records must contain the Relationship properties, if any, as well as the sourceId and targetId properties of the start and end nodes respectively.
224263
"""
264+
# Parse the data_model argument (handles both JSON string and dict)
265+
data_model_dict = parse_dict_from_json_input(data_model)
266+
data_model_obj = DataModel.model_validate(data_model_dict)
225267
logger.info(
226268
"Getting the Cypher query to ingest a list of Relationship records into a Neo4j database."
227269
)
228-
return data_model.get_relationship_cypher_ingest_query_for_many_records(
270+
return data_model_obj.get_relationship_cypher_ingest_query_for_many_records(
229271
relationship_type,
230272
relationship_start_node_label,
231273
relationship_end_node_label,
232274
)
233275

234276
@mcp.tool(name=namespace_prefix + "get_constraints_cypher_queries")
235-
def get_constraints_cypher_queries(data_model: DataModel) -> list[str]:
236-
"Get the Cypher queries to create constraints on the data model. This creates range indexes on the key properties of the nodes and relationships and enforces uniqueness and existence of the key properties."
277+
def get_constraints_cypher_queries(data_model: Union[str, DataModel]) -> list[str]:
278+
"""
279+
Get the Cypher queries to create constraints on the data model.
280+
This creates range indexes on the key properties of the nodes and relationships and enforces uniqueness and existence of the key properties.
281+
Accepts either a DataModel object or a JSON string of the DataModel object.
282+
"""
283+
# Parse the data_model argument (handles both JSON string and dict)
284+
data_model_dict = parse_dict_from_json_input(data_model)
285+
data_model_obj = DataModel.model_validate(data_model_dict)
237286
logger.info(
238287
"Getting the Cypher queries to create constraints on the data model."
239288
)
240-
return data_model.get_cypher_constraints_query()
289+
return data_model_obj.get_cypher_constraints_query()
241290

242291
@mcp.tool(name=namespace_prefix + "get_example_data_model")
243292
def get_example_data_model(
@@ -328,26 +377,30 @@ def list_example_data_models() -> dict[str, Any]:
328377
"total_examples": len(examples),
329378
"usage": "Use the get_example_data_model tool with any of the example names above to get a specific data model",
330379
}
331-
380+
332381
@mcp.tool(name=namespace_prefix + "load_from_owl_turtle")
333382
def load_from_owl_turtle(owl_turtle_str: str) -> DataModel:
334383
"""
335-
Load a data model from an OWL Turtle string.
384+
Load a data model from an OWL Turtle string.
336385
This process is lossy and some components of the ontology may be lost in the data model schema.
337386
Returns a DataModel object.
338387
"""
339388
logger.info("Loading a data model from an OWL Turtle string.")
340389
return DataModel.from_owl_turtle_str(owl_turtle_str)
341390

342391
@mcp.tool(name=namespace_prefix + "export_to_owl_turtle")
343-
def export_to_owl_turtle(data_model: DataModel) -> str:
392+
def export_to_owl_turtle(data_model: Union[str, DataModel]) -> str:
344393
"""
345-
Export a data model to an OWL Turtle string.
394+
Export a data model to an OWL Turtle string.
346395
This process is lossy since OWL does not support properties on relationships.
347396
Returns a string representation of the data model in OWL Turtle format.
397+
Accepts either a DataModel object or a JSON string of the DataModel object.
348398
"""
399+
# Parse the data_model argument (handles both JSON string and dict)
400+
data_model_dict = parse_dict_from_json_input(data_model)
401+
data_model_obj = DataModel.model_validate(data_model_dict)
349402
logger.info("Exporting a data model to an OWL Turtle string.")
350-
return data_model.to_owl_turtle_str()
403+
return data_model_obj.to_owl_turtle_str()
351404

352405
@mcp.prompt(title="Create New Data Model")
353406
def create_new_data_model(

0 commit comments

Comments
 (0)