diff --git a/CHANGELOG.md b/CHANGELOG.md index af4997a..1ef068c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 1. Add a new get_relationship_properties_keys tool. 2. Add targetNode filtering for longest_path. 3. Add support for similarity algorithms. +4. Add relationship directionality used by graph projection as an optional parameter to all appropriate algorithms. This include all algorithms that support both directed and undirected graphs and behave differently. ### Bug Fixes 1. Return node names in several path algorithms that only returned node ids. diff --git a/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_handlers.py b/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_handlers.py index 97f7892..716b7df 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_handlers.py +++ b/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_handlers.py @@ -13,8 +13,8 @@ class ArticleRankHandler(AlgorithmHandler): - def article_rank(self, **kwargs): - with projected_graph(self.gds) as G: + def article_rank(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter args = locals() params = { @@ -46,6 +46,7 @@ def article_rank(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.article_rank( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), sourceNodes=arguments.get("sourceNodes"), @@ -75,8 +76,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class BetweennessCentralityHandler(AlgorithmHandler): - def betweenness_centrality(self, **kwargs): - with projected_graph(self.gds) as G: + def betweenness_centrality(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -99,6 +100,7 @@ def betweenness_centrality(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.betweenness_centrality( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), samplingSize=arguments.get("samplingSize"), @@ -124,13 +126,13 @@ def bridges(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.bridges( - nodeIdentifierProperty=arguments.get("nodeIdentifierProperty") + nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), ) class CELFHandler(AlgorithmHandler): - def celf(self, **kwargs): - with projected_graph(self.gds) as G: + def celf(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -147,6 +149,7 @@ def celf(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.celf( + undirected=arguments.get("undirected", False), seedSetSize=arguments.get("seedSetSize"), monteCarloSimulations=arguments.get("monteCarloSimulations"), propagationProbability=arguments.get("propagationProbability"), @@ -155,8 +158,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class ClosenessCentralityHandler(AlgorithmHandler): - def closeness_centrality(self, **kwargs): - with projected_graph(self.gds) as G: + def closeness_centrality(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -174,11 +177,11 @@ def closeness_centrality(self, **kwargs): centrality = filter_identifiers( self.gds, node_identifier_property, node_names, centrality ) - return centrality def execute(self, arguments: Dict[str, Any]) -> Any: return self.closeness_centrality( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), useWassermanFaust=arguments.get("useWassermanFaust"), @@ -186,8 +189,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class DegreeCentralityHandler(AlgorithmHandler): - def degree_centrality(self, **kwargs): - with projected_graph(self.gds) as G: + def degree_centrality(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -210,6 +213,7 @@ def degree_centrality(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.degree_centrality( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), orientation=arguments.get("orientation"), @@ -217,8 +221,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class EigenvectorCentralityHandler(AlgorithmHandler): - def eigenvector_centrality(self, **kwargs): - with projected_graph(self.gds) as G: + def eigenvector_centrality(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -250,6 +254,7 @@ def eigenvector_centrality(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.eigenvector_centrality( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), maxIterations=arguments.get("maxIterations"), @@ -261,8 +266,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class PageRankHandler(AlgorithmHandler): - def pagerank(self, **kwargs): - with projected_graph(self.gds) as G: + def pagerank(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -293,6 +298,7 @@ def pagerank(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.pagerank( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), sourceNodes=arguments.get("sourceNodes"), @@ -303,8 +309,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class HarmonicCentralityHandler(AlgorithmHandler): - def harmonic_centrality(self, **kwargs): - with projected_graph(self.gds) as G: + def harmonic_centrality(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: centrality = self.gds.closeness.harmonic.stream(G) # Add node names to the results if nodeIdentifierProperty is provided @@ -320,14 +326,15 @@ def harmonic_centrality(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.harmonic_centrality( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), ) class HITSHandler(AlgorithmHandler): - def hits(self, **kwargs): - with projected_graph(self.gds) as G: + def hits(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -349,6 +356,7 @@ def hits(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.hits( + undirected=arguments.get("undirected", False), nodes=arguments.get("nodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), hitsIterations=arguments.get("hitsIterations"), diff --git a/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_specs.py b/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_specs.py index 3c71dbf..00bdb96 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_specs.py +++ b/mcp_server/src/mcp_server_neo4j_gds/centrality_algorithm_specs.py @@ -60,6 +60,10 @@ "Supported values are None, MinMax, Max, Mean, Log, and StdScore. " "To apply scaler-specific configuration, use the Map syntax: {scaler: 'name', ...}.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -101,6 +105,10 @@ "type": "string", "description": "Property of the relationship to use for weighting. If not specified, all relationships are treated equally.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -142,6 +150,10 @@ "type": "string", "description": "Property name to use for identifying nodes (e.g., 'name', 'Name', 'title'). Use get_node_properties_keys to find available properties.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["seedSetSize"], }, @@ -167,6 +179,10 @@ "type": "boolean", "description": "If true, uses the Wasserman-Faust formula for closeness centrality. ", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -190,6 +206,10 @@ "type": "string", "description": "The orientation used to compute node degrees. Supported orientations are NATURAL (for out-degree), REVERSE (for in-degree) and UNDIRECTED (for both in-degree and out-degree) ", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -256,6 +276,10 @@ "Supported values are None, MinMax, Max, Mean, Log, and StdScore. " "To apply scaler-specific configuration, use the Map syntax: {scaler: 'name', ...}.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ), @@ -307,6 +331,10 @@ }, ], }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -327,6 +355,10 @@ "type": "string", "description": "Property name to use for identifying nodes (e.g., 'name', 'Name', 'title'). Use get_node_properties_keys to find available properties.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -365,6 +397,10 @@ "enum": ["AUTO", "RANGE", "DEGREE"], "description": "The partitioning scheme used to divide the work between threads. Available options are AUTO, RANGE, DEGREE.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, diff --git a/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_handlers.py b/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_handlers.py index 91ddf1e..1279cd4 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_handlers.py +++ b/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_handlers.py @@ -13,8 +13,8 @@ class ConductanceHandler(AlgorithmHandler): - def conductance(self, **kwargs): - with projected_graph(self.gds) as G: + def conductance(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Conductance parameters: {kwargs}") conductance = self.gds.conductance.stream(G, **kwargs) @@ -22,6 +22,7 @@ def conductance(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.conductance( + undirected=arguments.get("undirected", False), communityProperty=arguments.get("communityProperty"), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) @@ -74,8 +75,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class K1ColoringHandler(AlgorithmHandler): - def k_1_coloring(self, **kwargs): - with projected_graph(self.gds) as G: + def k_1_coloring(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -94,6 +95,7 @@ def k_1_coloring(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.k_1_coloring( + undirected=arguments.get("undirected", False), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), maxIterations=arguments.get("maxIterations"), minCommunitySize=arguments.get("minCommunitySize"), @@ -134,13 +136,13 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class LabelPropagationHandler(AlgorithmHandler): - def label_propagation(self, **kwargs): + def label_propagation(self, undirected: bool = False, **kwargs): # Filter out nodeIdentifierProperty as it's not a GDS algorithm parameter gds_kwargs = { k: v for k, v in kwargs.items() if k not in ["nodeIdentifierProperty"] } - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Label Propagation parameters: {gds_kwargs}") label_propagation_result = self.gds.labelPropagation.stream(G, **gds_kwargs) @@ -154,6 +156,7 @@ def label_propagation(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.label_propagation( + undirected=arguments.get("undirected", False), maxIterations=arguments.get("maxIterations"), nodeWeightProperty=arguments.get("nodeWeightProperty"), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), @@ -239,13 +242,13 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class LouvainHandler(AlgorithmHandler): - def louvain(self, **kwargs): + def louvain(self, undirected: bool = False, **kwargs): # Filter out nodeIdentifierProperty as it's not a GDS algorithm parameter gds_kwargs = { k: v for k, v in kwargs.items() if k not in ["nodeIdentifierProperty"] } - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Louvain parameters: {gds_kwargs}") louvain_result = self.gds.louvain.stream(G, **gds_kwargs) @@ -257,6 +260,7 @@ def louvain(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.louvain( + undirected=arguments.get("undirected", False), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), seedProperty=arguments.get("seedProperty"), maxLevels=arguments.get("maxLevels"), @@ -272,8 +276,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class ModularityMetricHandler(AlgorithmHandler): - def modularity_metric(self, **kwargs): - with projected_graph(self.gds) as G: + def modularity_metric(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Modularity Metric parameters: {kwargs}") modularity_metric_result = self.gds.modularity.stream(G, **kwargs) @@ -281,19 +285,20 @@ def modularity_metric(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.modularity_metric( + undirected=arguments.get("undirected", False), communityProperty=arguments.get("communityProperty"), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) class ModularityOptimizationHandler(AlgorithmHandler): - def modularity_optimization(self, **kwargs): + def modularity_optimization(self, undirected: bool = False, **kwargs): # Filter out nodeIdentifierProperty as it's not a GDS algorithm parameter gds_kwargs = { k: v for k, v in kwargs.items() if k not in ["nodeIdentifierProperty"] } - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Modularity Optimization parameters: {gds_kwargs}") modularity_optimization_result = self.gds.modularityOptimization.stream( G, **gds_kwargs @@ -309,6 +314,7 @@ def modularity_optimization(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.modularity_optimization( + undirected=arguments.get("undirected", False), maxIterations=arguments.get("maxIterations"), tolerance=arguments.get("tolerance"), seedProperty=arguments.get("seedProperty"), @@ -413,13 +419,13 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class ApproximateMaximumKCutHandler(AlgorithmHandler): - def approximate_maximum_k_cut(self, **kwargs): + def approximate_maximum_k_cut(self, undirected: bool = False, **kwargs): # Filter out nodeIdentifierProperty as it's not a GDS algorithm parameter gds_kwargs = { k: v for k, v in kwargs.items() if k not in ["nodeIdentifierProperty"] } - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Approximate Maximum K Cut parameters: {gds_kwargs}") approximate_maximum_k_cut_result = self.gds.maxkcut.stream(G, **gds_kwargs) @@ -433,6 +439,7 @@ def approximate_maximum_k_cut(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.approximate_maximum_k_cut( + undirected=arguments.get("undirected", False), k=arguments.get("k"), iterations=arguments.get("iterations"), vnsMaxNeighborhoodOrder=arguments.get("vnsMaxNeighborhoodOrder"), @@ -443,13 +450,13 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class SpeakerListenerLabelPropagationHandler(AlgorithmHandler): - def speaker_listener_label_propagation(self, **kwargs): + def speaker_listener_label_propagation(self, undirected: bool = False, **kwargs): # Filter out nodeIdentifierProperty as it's not a GDS algorithm parameter gds_kwargs = { k: v for k, v in kwargs.items() if k not in ["nodeIdentifierProperty"] } - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: logger.info(f"Speaker Listener Label Propagation parameters: {gds_kwargs}") speaker_listener_label_propagation_result = self.gds.sllpa.stream( G, **gds_kwargs @@ -467,6 +474,7 @@ def speaker_listener_label_propagation(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.speaker_listener_label_propagation( + undirected=arguments.get("undirected", False), maxIterations=arguments.get("maxIterations"), minAssociationStrength=arguments.get("minAssociationStrength"), partitioning=arguments.get("partitioning"), diff --git a/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_specs.py b/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_specs.py index 5f74194..328e2ca 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_specs.py +++ b/mcp_server/src/mcp_server_neo4j_gds/community_algorithm_specs.py @@ -17,6 +17,10 @@ "description": "The relationship property that holds the weight of the relationships. " "If not provided, all relationships are considered to have a weight of 1.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["communityProperty"], }, @@ -105,6 +109,10 @@ "type": "integer", "description": "Only nodes inside communities larger or equal the given value are returned.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -202,6 +210,10 @@ "type": "string", "description": "The name of a node property to use as node identifier in the result. If provided, the result will include a 'nodeName' column with values from this property.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ), @@ -325,6 +337,10 @@ "type": "string", "description": "The name of a node property to use as node identifier in the result. If provided, the result will include a 'nodeName' column with values from this property.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ), @@ -344,6 +360,10 @@ "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["communityProperty"], }, @@ -385,6 +405,10 @@ "type": "string", "description": "The name of a node property to use as node identifier in the result. If provided, the result will include a 'nodeName' column with values from this property.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ), @@ -510,6 +534,10 @@ "type": "string", "description": "The name of a node property to use as node identifier in the result. If provided, the result will include a 'nodeName' column with values from this property.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ), @@ -539,6 +567,10 @@ "type": "string", "description": "The name of a node property to use as node identifier in the result. If provided, the result will include a 'nodeName' column with values from this property.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ), diff --git a/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_handlers.py b/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_handlers.py index 044f760..56c70d0 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_handlers.py +++ b/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_handlers.py @@ -10,7 +10,12 @@ class DijkstraShortestPathHandler(AlgorithmHandler): def find_shortest_path( - self, start_node: str, end_node: str, node_identifier_property: str, **kwargs + self, + start_node: str, + end_node: str, + node_identifier_property: str, + undirected: bool = False, + **kwargs, ): query = f""" MATCH (start) @@ -30,7 +35,7 @@ def find_shortest_path( start_node_id = int(df["start_id"].iloc[0]) end_node_id = int(df["end_id"].iloc[0]) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter args = locals() params = {k: v for k, v in kwargs.items() if v is not None} @@ -72,13 +77,18 @@ def execute(self, arguments: Dict[str, Any]) -> Any: arguments.get("start_node"), arguments.get("end_node"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), relationshipWeightProperty=arguments.get("relationship_property"), ) class DeltaSteppingShortestPathHandler(AlgorithmHandler): def delta_stepping_shortest_path( - self, source_node: str, node_identifier_property: str, **kwargs + self, + source_node: str, + node_identifier_property: str, + undirected: bool = False, + **kwargs, ): query = f""" MATCH (source) @@ -93,7 +103,7 @@ def delta_stepping_shortest_path( source_node_id = int(df["source_id"].iloc[0]) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter params = {k: v for k, v in kwargs.items() if v is not None} logger.info(f"Delta-Stepping shortest path parameters: {params}") @@ -153,6 +163,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: return self.delta_stepping_shortest_path( arguments.get("sourceNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), delta=arguments.get("delta"), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) @@ -160,7 +171,11 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class DijkstraSingleSourceShortestPathHandler(AlgorithmHandler): def dijkstra_single_source_shortest_path( - self, source_node: str, node_identifier_property: str, **kwargs + self, + source_node: str, + node_identifier_property: str, + undirected: bool = False, + **kwargs, ): query = f""" MATCH (source) @@ -175,7 +190,7 @@ def dijkstra_single_source_shortest_path( source_node_id = int(df["source_id"].iloc[0]) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter params = {k: v for k, v in kwargs.items() if v is not None} logger.info(f"Dijkstra single-source shortest path parameters: {params}") @@ -234,6 +249,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: return self.dijkstra_single_source_shortest_path( arguments.get("sourceNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) @@ -244,6 +260,7 @@ def a_star_shortest_path( source_node: str, target_node: str, node_identifier_property: str, + undirected: bool = False, **kwargs, ): query = f""" @@ -264,7 +281,7 @@ def a_star_shortest_path( source_node_id = int(df["source_id"].iloc[0]) target_node_id = int(df["target_id"].iloc[0]) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter params = {k: v for k, v in kwargs.items() if v is not None} logger.info(f"A* shortest path parameters: {params}") @@ -305,6 +322,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: arguments.get("sourceNode"), arguments.get("targetNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), latitudeProperty=arguments.get("latitudeProperty"), longitudeProperty=arguments.get("longitudeProperty"), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), @@ -317,6 +335,7 @@ def yens_shortest_paths( source_node: str, target_node: str, node_identifier_property: str, + undirected: bool = False, **kwargs, ): query = f""" @@ -337,7 +356,7 @@ def yens_shortest_paths( source_node_id = int(df["source_id"].iloc[0]) target_node_id = int(df["target_id"].iloc[0]) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter params = {k: v for k, v in kwargs.items() if v is not None} logger.info(f"Yen's shortest paths parameters: {params}") @@ -394,6 +413,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: arguments.get("sourceNode"), arguments.get("targetNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), k=arguments.get("k"), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) @@ -481,6 +501,7 @@ def minimum_directed_steiner_tree( source_node: str, target_nodes: list, node_identifier_property: str, + undirected: bool = False, **kwargs, ): # Find source node ID @@ -531,7 +552,7 @@ def minimum_directed_steiner_tree( if not target_node_ids: return {"found": False, "message": "No target nodes found"} - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter params = {k: v for k, v in kwargs.items() if v is not None} logger.info(f"Minimum Directed Steiner Tree parameters: {params}") @@ -587,6 +608,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: arguments.get("sourceNode"), arguments.get("targetNodes"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), delta=arguments.get("delta"), applyRerouting=arguments.get("applyRerouting"), @@ -652,8 +674,8 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class AllPairsShortestPathsHandler(AlgorithmHandler): - def all_pairs_shortest_paths(self, **kwargs): - with projected_graph(self.gds) as G: + def all_pairs_shortest_paths(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: # If any optional parameter is not None, use that parameter params = {k: v for k, v in kwargs.items() if v is not None} logger.info(f"All Pairs Shortest Paths parameters: {params}") @@ -693,12 +715,13 @@ def all_pairs_shortest_paths(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.all_pairs_shortest_paths( - relationshipWeightProperty=arguments.get("relationshipWeightProperty") + undirected=arguments.get("undirected", False), + relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) class RandomWalkHandler(AlgorithmHandler): - def random_walk(self, **kwargs): + def random_walk(self, undirected: bool = False, **kwargs): # Process source nodes if provided source_node_ids = [] if "sourceNodes" in kwargs and kwargs["sourceNodes"]: @@ -723,7 +746,7 @@ def random_walk(self, **kwargs): if not source_df.empty: source_node_ids.append(int(source_df["source_id"].iloc[0])) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # Prepare parameters for the random walk algorithm, excluding our internal parameters params = { k: v @@ -770,6 +793,7 @@ def random_walk(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.random_walk( + undirected=arguments.get("undirected", False), sourceNodes=arguments.get("sourceNodes"), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), walkLength=arguments.get("walkLength"), @@ -783,7 +807,11 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class BreadthFirstSearchHandler(AlgorithmHandler): def breadth_first_search( - self, source_node: str, node_identifier_property: str, **kwargs + self, + source_node: str, + node_identifier_property: str, + undirected: bool = False, + **kwargs, ): # Find source node ID source_query = f""" @@ -818,7 +846,7 @@ def breadth_first_search( if not target_df.empty: target_node_ids.append(int(target_df["target_id"].iloc[0])) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # Prepare parameters for the BFS algorithm, excluding our internal parameters params = { k: v @@ -873,6 +901,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: return self.breadth_first_search( arguments.get("sourceNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), targetNodes=arguments.get("targetNodes"), maxDepth=arguments.get("maxDepth"), ) @@ -880,7 +909,11 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class DepthFirstSearchHandler(AlgorithmHandler): def depth_first_search( - self, source_node: str, node_identifier_property: str, **kwargs + self, + source_node: str, + node_identifier_property: str, + undirected: bool = False, + **kwargs, ): # Find source node ID source_query = f""" @@ -915,7 +948,7 @@ def depth_first_search( if not target_df.empty: target_node_ids.append(int(target_df["target_id"].iloc[0])) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # Prepare parameters for the DFS algorithm, excluding our internal parameters params = { k: v @@ -970,6 +1003,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: return self.depth_first_search( arguments.get("sourceNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), targetNodes=arguments.get("targetNodes"), maxDepth=arguments.get("maxDepth"), ) @@ -977,7 +1011,11 @@ def execute(self, arguments: Dict[str, Any]) -> Any: class BellmanFordSingleSourceShortestPathHandler(AlgorithmHandler): def bellman_ford_single_source_shortest_path( - self, source_node: str, node_identifier_property: str, **kwargs + self, + source_node: str, + node_identifier_property: str, + undirected: bool = False, + **kwargs, ): # Find source node ID source_query = f""" @@ -995,7 +1033,7 @@ def bellman_ford_single_source_shortest_path( source_node_id = int(source_df["source_id"].iloc[0]) - with projected_graph(self.gds) as G: + with projected_graph(self.gds, undirected=undirected) as G: # Prepare parameters for the Bellman-Ford algorithm, excluding our internal parameters params = { k: v @@ -1060,6 +1098,7 @@ def execute(self, arguments: Dict[str, Any]) -> Any: return self.bellman_ford_single_source_shortest_path( arguments.get("sourceNode"), arguments.get("nodeIdentifierProperty"), + undirected=arguments.get("undirected", False), relationshipWeightProperty=arguments.get("relationshipWeightProperty"), ) diff --git a/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_specs.py b/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_specs.py index bf0e669..20a180a 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_specs.py +++ b/mcp_server/src/mcp_server_neo4j_gds/path_algorithm_specs.py @@ -23,6 +23,10 @@ "type": "string", "description": "Property of the relationship to use for path finding", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["start_node", "end_node", "nodeIdentifierProperty"], }, @@ -53,6 +57,10 @@ "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "nodeIdentifierProperty"], }, @@ -78,6 +86,10 @@ "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "nodeIdentifierProperty"], }, @@ -121,6 +133,10 @@ "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [ "sourceNode", @@ -166,6 +182,10 @@ "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "targetNode", "nodeIdentifierProperty"], }, @@ -244,6 +264,10 @@ "type": "boolean", "description": "If specified, the algorithm will try to improve the outcome via an additional post-processing heuristic.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "targetNodes", "nodeIdentifierProperty"], }, @@ -289,7 +313,11 @@ "relationshipWeightProperty": { "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", - } + }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -343,6 +371,10 @@ "type": "integer", "description": "The number of random walks to complete before starting training.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": [], }, @@ -374,6 +406,10 @@ "type": "integer", "description": "The maximum distance from the source node at which nodes are visited.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "nodeIdentifierProperty"], }, @@ -405,6 +441,10 @@ "type": "integer", "description": "The maximum distance from the source node at which nodes are visited.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "nodeIdentifierProperty"], }, @@ -437,6 +477,10 @@ "type": "string", "description": "Name of the relationship property to use as weights. If unspecified, the algorithm runs unweighted.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, "required": ["sourceNode", "nodeIdentifierProperty"], }, diff --git a/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_handlers.py b/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_handlers.py index fbb2d9a..e11a860 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_handlers.py +++ b/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_handlers.py @@ -12,8 +12,8 @@ class NodeSimilarityHandler(AlgorithmHandler): - def node_similarity(self, **kwargs): - with projected_graph(self.gds) as G: + def node_similarity(self, undirected: bool = False, **kwargs): + with projected_graph(self.gds, undirected=undirected) as G: params = { k: v for k, v in kwargs.items() @@ -67,6 +67,7 @@ def node_similarity(self, **kwargs): def execute(self, arguments: Dict[str, Any]) -> Any: return self.node_similarity( + undirected=arguments.get("undirected", False), nodeIdentifierProperty=arguments.get("nodeIdentifierProperty"), sourceNodeFilter=arguments.get("sourceNodeFilter"), targetNodeFilter=arguments.get("targetNodeFilter"), diff --git a/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_specs.py b/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_specs.py index 446e382..3a45a4d 100644 --- a/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_specs.py +++ b/mcp_server/src/mcp_server_neo4j_gds/similarity_algorithm_specs.py @@ -64,6 +64,10 @@ "type": "string", "description": "Property name to use for identifying nodes (e.g., 'name', 'Name', 'title'). Use get_node_properties_keys to find available properties.", }, + "undirected": { + "type": "boolean", + "description": "Whether to treat the graph as undirected or not. Default is false (directed).", + }, }, }, ),