#
tokens: 44511/50000 4/236 files (page 9/12)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 9 of 12. Use http://codebase.md/getzep/graphiti?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .env.example
├── .github
│   ├── dependabot.yml
│   ├── ISSUE_TEMPLATE
│   │   └── bug_report.md
│   ├── pull_request_template.md
│   ├── secret_scanning.yml
│   └── workflows
│       ├── ai-moderator.yml
│       ├── cla.yml
│       ├── claude-code-review-manual.yml
│       ├── claude-code-review.yml
│       ├── claude.yml
│       ├── codeql.yml
│       ├── lint.yml
│       ├── release-graphiti-core.yml
│       ├── release-mcp-server.yml
│       ├── release-server-container.yml
│       ├── typecheck.yml
│       └── unit_tests.yml
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── conftest.py
├── CONTRIBUTING.md
├── depot.json
├── docker-compose.test.yml
├── docker-compose.yml
├── Dockerfile
├── ellipsis.yaml
├── examples
│   ├── azure-openai
│   │   ├── .env.example
│   │   ├── azure_openai_neo4j.py
│   │   └── README.md
│   ├── data
│   │   └── manybirds_products.json
│   ├── ecommerce
│   │   ├── runner.ipynb
│   │   └── runner.py
│   ├── langgraph-agent
│   │   ├── agent.ipynb
│   │   └── tinybirds-jess.png
│   ├── opentelemetry
│   │   ├── .env.example
│   │   ├── otel_stdout_example.py
│   │   ├── pyproject.toml
│   │   ├── README.md
│   │   └── uv.lock
│   ├── podcast
│   │   ├── podcast_runner.py
│   │   ├── podcast_transcript.txt
│   │   └── transcript_parser.py
│   ├── quickstart
│   │   ├── dense_vs_normal_ingestion.py
│   │   ├── quickstart_falkordb.py
│   │   ├── quickstart_neo4j.py
│   │   ├── quickstart_neptune.py
│   │   ├── README.md
│   │   └── requirements.txt
│   └── wizard_of_oz
│       ├── parser.py
│       ├── runner.py
│       └── woo.txt
├── graphiti_core
│   ├── __init__.py
│   ├── cross_encoder
│   │   ├── __init__.py
│   │   ├── bge_reranker_client.py
│   │   ├── client.py
│   │   ├── gemini_reranker_client.py
│   │   └── openai_reranker_client.py
│   ├── decorators.py
│   ├── driver
│   │   ├── __init__.py
│   │   ├── driver.py
│   │   ├── falkordb_driver.py
│   │   ├── graph_operations
│   │   │   └── graph_operations.py
│   │   ├── kuzu_driver.py
│   │   ├── neo4j_driver.py
│   │   ├── neptune_driver.py
│   │   └── search_interface
│   │       └── search_interface.py
│   ├── edges.py
│   ├── embedder
│   │   ├── __init__.py
│   │   ├── azure_openai.py
│   │   ├── client.py
│   │   ├── gemini.py
│   │   ├── openai.py
│   │   └── voyage.py
│   ├── errors.py
│   ├── graph_queries.py
│   ├── graphiti_types.py
│   ├── graphiti.py
│   ├── helpers.py
│   ├── llm_client
│   │   ├── __init__.py
│   │   ├── anthropic_client.py
│   │   ├── azure_openai_client.py
│   │   ├── client.py
│   │   ├── config.py
│   │   ├── errors.py
│   │   ├── gemini_client.py
│   │   ├── groq_client.py
│   │   ├── openai_base_client.py
│   │   ├── openai_client.py
│   │   ├── openai_generic_client.py
│   │   └── utils.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models
│   │   ├── __init__.py
│   │   ├── edges
│   │   │   ├── __init__.py
│   │   │   └── edge_db_queries.py
│   │   └── nodes
│   │       ├── __init__.py
│   │       └── node_db_queries.py
│   ├── nodes.py
│   ├── prompts
│   │   ├── __init__.py
│   │   ├── dedupe_edges.py
│   │   ├── dedupe_nodes.py
│   │   ├── eval.py
│   │   ├── extract_edge_dates.py
│   │   ├── extract_edges.py
│   │   ├── extract_nodes.py
│   │   ├── invalidate_edges.py
│   │   ├── lib.py
│   │   ├── models.py
│   │   ├── prompt_helpers.py
│   │   ├── snippets.py
│   │   └── summarize_nodes.py
│   ├── py.typed
│   ├── search
│   │   ├── __init__.py
│   │   ├── search_config_recipes.py
│   │   ├── search_config.py
│   │   ├── search_filters.py
│   │   ├── search_helpers.py
│   │   ├── search_utils.py
│   │   └── search.py
│   ├── telemetry
│   │   ├── __init__.py
│   │   └── telemetry.py
│   ├── tracer.py
│   └── utils
│       ├── __init__.py
│       ├── bulk_utils.py
│       ├── content_chunking.py
│       ├── datetime_utils.py
│       ├── maintenance
│       │   ├── __init__.py
│       │   ├── community_operations.py
│       │   ├── dedup_helpers.py
│       │   ├── edge_operations.py
│       │   ├── graph_data_operations.py
│       │   ├── node_operations.py
│       │   └── temporal_operations.py
│       ├── ontology_utils
│       │   └── entity_types_utils.py
│       └── text_utils.py
├── images
│   ├── arxiv-screenshot.png
│   ├── graphiti-graph-intro.gif
│   ├── graphiti-intro-slides-stock-2.gif
│   └── simple_graph.svg
├── LICENSE
├── Makefile
├── mcp_server
│   ├── .env.example
│   ├── .python-version
│   ├── config
│   │   ├── config-docker-falkordb-combined.yaml
│   │   ├── config-docker-falkordb.yaml
│   │   ├── config-docker-neo4j.yaml
│   │   ├── config.yaml
│   │   └── mcp_config_stdio_example.json
│   ├── docker
│   │   ├── build-standalone.sh
│   │   ├── build-with-version.sh
│   │   ├── docker-compose-falkordb.yml
│   │   ├── docker-compose-neo4j.yml
│   │   ├── docker-compose.yml
│   │   ├── Dockerfile
│   │   ├── Dockerfile.standalone
│   │   ├── github-actions-example.yml
│   │   ├── README-falkordb-combined.md
│   │   └── README.md
│   ├── docs
│   │   └── cursor_rules.md
│   ├── main.py
│   ├── pyproject.toml
│   ├── pytest.ini
│   ├── README.md
│   ├── src
│   │   ├── __init__.py
│   │   ├── config
│   │   │   ├── __init__.py
│   │   │   └── schema.py
│   │   ├── graphiti_mcp_server.py
│   │   ├── models
│   │   │   ├── __init__.py
│   │   │   ├── entity_types.py
│   │   │   └── response_types.py
│   │   ├── services
│   │   │   ├── __init__.py
│   │   │   ├── factories.py
│   │   │   └── queue_service.py
│   │   └── utils
│   │       ├── __init__.py
│   │       ├── formatting.py
│   │       └── utils.py
│   ├── tests
│   │   ├── __init__.py
│   │   ├── conftest.py
│   │   ├── pytest.ini
│   │   ├── README.md
│   │   ├── run_tests.py
│   │   ├── test_async_operations.py
│   │   ├── test_comprehensive_integration.py
│   │   ├── test_configuration.py
│   │   ├── test_falkordb_integration.py
│   │   ├── test_fixtures.py
│   │   ├── test_http_integration.py
│   │   ├── test_integration.py
│   │   ├── test_mcp_integration.py
│   │   ├── test_mcp_transports.py
│   │   ├── test_stdio_simple.py
│   │   └── test_stress_load.py
│   └── uv.lock
├── OTEL_TRACING.md
├── py.typed
├── pyproject.toml
├── pytest.ini
├── README.md
├── SECURITY.md
├── server
│   ├── .env.example
│   ├── graph_service
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── dto
│   │   │   ├── __init__.py
│   │   │   ├── common.py
│   │   │   ├── ingest.py
│   │   │   └── retrieve.py
│   │   ├── main.py
│   │   ├── routers
│   │   │   ├── __init__.py
│   │   │   ├── ingest.py
│   │   │   └── retrieve.py
│   │   └── zep_graphiti.py
│   ├── Makefile
│   ├── pyproject.toml
│   ├── README.md
│   └── uv.lock
├── signatures
│   └── version1
│       └── cla.json
├── tests
│   ├── cross_encoder
│   │   ├── test_bge_reranker_client_int.py
│   │   └── test_gemini_reranker_client.py
│   ├── driver
│   │   ├── __init__.py
│   │   └── test_falkordb_driver.py
│   ├── embedder
│   │   ├── embedder_fixtures.py
│   │   ├── test_gemini.py
│   │   ├── test_openai.py
│   │   └── test_voyage.py
│   ├── evals
│   │   ├── data
│   │   │   └── longmemeval_data
│   │   │       ├── longmemeval_oracle.json
│   │   │       └── README.md
│   │   ├── eval_cli.py
│   │   ├── eval_e2e_graph_building.py
│   │   ├── pytest.ini
│   │   └── utils.py
│   ├── helpers_test.py
│   ├── llm_client
│   │   ├── test_anthropic_client_int.py
│   │   ├── test_anthropic_client.py
│   │   ├── test_azure_openai_client.py
│   │   ├── test_client.py
│   │   ├── test_errors.py
│   │   └── test_gemini_client.py
│   ├── test_edge_int.py
│   ├── test_entity_exclusion_int.py
│   ├── test_graphiti_int.py
│   ├── test_graphiti_mock.py
│   ├── test_node_int.py
│   ├── test_text_utils.py
│   └── utils
│       ├── maintenance
│       │   ├── test_bulk_utils.py
│       │   ├── test_edge_operations.py
│       │   ├── test_entity_extraction.py
│       │   ├── test_node_operations.py
│       │   └── test_temporal_operations_int.py
│       ├── search
│       │   └── search_utils_test.py
│       └── test_content_chunking.py
├── uv.lock
└── Zep-CLA.md
```

# Files

--------------------------------------------------------------------------------
/graphiti_core/utils/maintenance/edge_operations.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Copyright 2024, Zep Software, Inc.
  3 | 
  4 | Licensed under the Apache License, Version 2.0 (the "License");
  5 | you may not use this file except in compliance with the License.
  6 | You may obtain a copy of the License at
  7 | 
  8 |     http://www.apache.org/licenses/LICENSE-2.0
  9 | 
 10 | Unless required by applicable law or agreed to in writing, software
 11 | distributed under the License is distributed on an "AS IS" BASIS,
 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 13 | See the License for the specific language governing permissions and
 14 | limitations under the License.
 15 | """
 16 | 
 17 | import logging
 18 | from datetime import datetime
 19 | from time import time
 20 | 
 21 | from pydantic import BaseModel
 22 | from typing_extensions import LiteralString
 23 | 
 24 | from graphiti_core.driver.driver import GraphDriver, GraphProvider
 25 | from graphiti_core.edges import (
 26 |     CommunityEdge,
 27 |     EntityEdge,
 28 |     EpisodicEdge,
 29 |     create_entity_edge_embeddings,
 30 | )
 31 | from graphiti_core.graphiti_types import GraphitiClients
 32 | from graphiti_core.helpers import semaphore_gather
 33 | from graphiti_core.llm_client import LLMClient
 34 | from graphiti_core.llm_client.config import ModelSize
 35 | from graphiti_core.nodes import CommunityNode, EntityNode, EpisodicNode
 36 | from graphiti_core.prompts import prompt_library
 37 | from graphiti_core.prompts.dedupe_edges import EdgeDuplicate
 38 | from graphiti_core.prompts.extract_edges import ExtractedEdges
 39 | from graphiti_core.search.search import search
 40 | from graphiti_core.search.search_config import SearchResults
 41 | from graphiti_core.search.search_config_recipes import EDGE_HYBRID_SEARCH_RRF
 42 | from graphiti_core.search.search_filters import SearchFilters
 43 | from graphiti_core.utils.datetime_utils import ensure_utc, utc_now
 44 | from graphiti_core.utils.maintenance.dedup_helpers import _normalize_string_exact
 45 | 
 46 | DEFAULT_EDGE_NAME = 'RELATES_TO'
 47 | 
 48 | logger = logging.getLogger(__name__)
 49 | 
 50 | 
 51 | def build_episodic_edges(
 52 |     entity_nodes: list[EntityNode],
 53 |     episode_uuid: str,
 54 |     created_at: datetime,
 55 | ) -> list[EpisodicEdge]:
 56 |     episodic_edges: list[EpisodicEdge] = [
 57 |         EpisodicEdge(
 58 |             source_node_uuid=episode_uuid,
 59 |             target_node_uuid=node.uuid,
 60 |             created_at=created_at,
 61 |             group_id=node.group_id,
 62 |         )
 63 |         for node in entity_nodes
 64 |     ]
 65 | 
 66 |     logger.debug(f'Built episodic edges: {episodic_edges}')
 67 | 
 68 |     return episodic_edges
 69 | 
 70 | 
 71 | def build_community_edges(
 72 |     entity_nodes: list[EntityNode],
 73 |     community_node: CommunityNode,
 74 |     created_at: datetime,
 75 | ) -> list[CommunityEdge]:
 76 |     edges: list[CommunityEdge] = [
 77 |         CommunityEdge(
 78 |             source_node_uuid=community_node.uuid,
 79 |             target_node_uuid=node.uuid,
 80 |             created_at=created_at,
 81 |             group_id=community_node.group_id,
 82 |         )
 83 |         for node in entity_nodes
 84 |     ]
 85 | 
 86 |     return edges
 87 | 
 88 | 
 89 | async def extract_edges(
 90 |     clients: GraphitiClients,
 91 |     episode: EpisodicNode,
 92 |     nodes: list[EntityNode],
 93 |     previous_episodes: list[EpisodicNode],
 94 |     edge_type_map: dict[tuple[str, str], list[str]],
 95 |     group_id: str = '',
 96 |     edge_types: dict[str, type[BaseModel]] | None = None,
 97 |     custom_extraction_instructions: str | None = None,
 98 | ) -> list[EntityEdge]:
 99 |     start = time()
100 | 
101 |     extract_edges_max_tokens = 16384
102 |     llm_client = clients.llm_client
103 | 
104 |     edge_type_signature_map: dict[str, tuple[str, str]] = {
105 |         edge_type: signature
106 |         for signature, edge_types in edge_type_map.items()
107 |         for edge_type in edge_types
108 |     }
109 | 
110 |     edge_types_context = (
111 |         [
112 |             {
113 |                 'fact_type_name': type_name,
114 |                 'fact_type_signature': edge_type_signature_map.get(type_name, ('Entity', 'Entity')),
115 |                 'fact_type_description': type_model.__doc__,
116 |             }
117 |             for type_name, type_model in edge_types.items()
118 |         ]
119 |         if edge_types is not None
120 |         else []
121 |     )
122 | 
123 |     # Prepare context for LLM
124 |     context = {
125 |         'episode_content': episode.content,
126 |         'nodes': [
127 |             {'id': idx, 'name': node.name, 'entity_types': node.labels}
128 |             for idx, node in enumerate(nodes)
129 |         ],
130 |         'previous_episodes': [ep.content for ep in previous_episodes],
131 |         'reference_time': episode.valid_at,
132 |         'edge_types': edge_types_context,
133 |         'custom_extraction_instructions': custom_extraction_instructions or '',
134 |     }
135 | 
136 |     llm_response = await llm_client.generate_response(
137 |         prompt_library.extract_edges.edge(context),
138 |         response_model=ExtractedEdges,
139 |         max_tokens=extract_edges_max_tokens,
140 |         group_id=group_id,
141 |         prompt_name='extract_edges.edge',
142 |     )
143 |     edges_data = ExtractedEdges(**llm_response).edges
144 | 
145 |     end = time()
146 |     logger.debug(f'Extracted new edges: {edges_data} in {(end - start) * 1000} ms')
147 | 
148 |     if len(edges_data) == 0:
149 |         return []
150 | 
151 |     # Convert the extracted data into EntityEdge objects
152 |     edges = []
153 |     for edge_data in edges_data:
154 |         # Validate Edge Date information
155 |         valid_at = edge_data.valid_at
156 |         invalid_at = edge_data.invalid_at
157 |         valid_at_datetime = None
158 |         invalid_at_datetime = None
159 | 
160 |         # Filter out empty edges
161 |         if not edge_data.fact.strip():
162 |             continue
163 | 
164 |         source_node_idx = edge_data.source_entity_id
165 |         target_node_idx = edge_data.target_entity_id
166 | 
167 |         if len(nodes) == 0:
168 |             logger.warning('No entities provided for edge extraction')
169 |             continue
170 | 
171 |         if not (0 <= source_node_idx < len(nodes) and 0 <= target_node_idx < len(nodes)):
172 |             logger.warning(
173 |                 f'Invalid entity IDs in edge extraction for {edge_data.relation_type}. '
174 |                 f'source_entity_id: {source_node_idx}, target_entity_id: {target_node_idx}, '
175 |                 f'but only {len(nodes)} entities available (valid range: 0-{len(nodes) - 1})'
176 |             )
177 |             continue
178 |         source_node_uuid = nodes[source_node_idx].uuid
179 |         target_node_uuid = nodes[target_node_idx].uuid
180 | 
181 |         if valid_at:
182 |             try:
183 |                 valid_at_datetime = ensure_utc(
184 |                     datetime.fromisoformat(valid_at.replace('Z', '+00:00'))
185 |                 )
186 |             except ValueError as e:
187 |                 logger.warning(f'WARNING: Error parsing valid_at date: {e}. Input: {valid_at}')
188 | 
189 |         if invalid_at:
190 |             try:
191 |                 invalid_at_datetime = ensure_utc(
192 |                     datetime.fromisoformat(invalid_at.replace('Z', '+00:00'))
193 |                 )
194 |             except ValueError as e:
195 |                 logger.warning(f'WARNING: Error parsing invalid_at date: {e}. Input: {invalid_at}')
196 |         edge = EntityEdge(
197 |             source_node_uuid=source_node_uuid,
198 |             target_node_uuid=target_node_uuid,
199 |             name=edge_data.relation_type,
200 |             group_id=group_id,
201 |             fact=edge_data.fact,
202 |             episodes=[episode.uuid],
203 |             created_at=utc_now(),
204 |             valid_at=valid_at_datetime,
205 |             invalid_at=invalid_at_datetime,
206 |         )
207 |         edges.append(edge)
208 |         logger.debug(
209 |             f'Created new edge: {edge.name} from (UUID: {edge.source_node_uuid}) to (UUID: {edge.target_node_uuid})'
210 |         )
211 | 
212 |     logger.debug(f'Extracted edges: {[(e.name, e.uuid) for e in edges]}')
213 | 
214 |     return edges
215 | 
216 | 
217 | async def resolve_extracted_edges(
218 |     clients: GraphitiClients,
219 |     extracted_edges: list[EntityEdge],
220 |     episode: EpisodicNode,
221 |     entities: list[EntityNode],
222 |     edge_types: dict[str, type[BaseModel]],
223 |     edge_type_map: dict[tuple[str, str], list[str]],
224 | ) -> tuple[list[EntityEdge], list[EntityEdge]]:
225 |     # Fast path: deduplicate exact matches within the extracted edges before parallel processing
226 |     seen: dict[tuple[str, str, str], EntityEdge] = {}
227 |     deduplicated_edges: list[EntityEdge] = []
228 | 
229 |     for edge in extracted_edges:
230 |         key = (
231 |             edge.source_node_uuid,
232 |             edge.target_node_uuid,
233 |             _normalize_string_exact(edge.fact),
234 |         )
235 |         if key not in seen:
236 |             seen[key] = edge
237 |             deduplicated_edges.append(edge)
238 | 
239 |     extracted_edges = deduplicated_edges
240 | 
241 |     driver = clients.driver
242 |     llm_client = clients.llm_client
243 |     embedder = clients.embedder
244 |     await create_entity_edge_embeddings(embedder, extracted_edges)
245 | 
246 |     valid_edges_list: list[list[EntityEdge]] = await semaphore_gather(
247 |         *[
248 |             EntityEdge.get_between_nodes(driver, edge.source_node_uuid, edge.target_node_uuid)
249 |             for edge in extracted_edges
250 |         ]
251 |     )
252 | 
253 |     related_edges_results: list[SearchResults] = await semaphore_gather(
254 |         *[
255 |             search(
256 |                 clients,
257 |                 extracted_edge.fact,
258 |                 group_ids=[extracted_edge.group_id],
259 |                 config=EDGE_HYBRID_SEARCH_RRF,
260 |                 search_filter=SearchFilters(edge_uuids=[edge.uuid for edge in valid_edges]),
261 |             )
262 |             for extracted_edge, valid_edges in zip(extracted_edges, valid_edges_list, strict=True)
263 |         ]
264 |     )
265 | 
266 |     related_edges_lists: list[list[EntityEdge]] = [result.edges for result in related_edges_results]
267 | 
268 |     edge_invalidation_candidate_results: list[SearchResults] = await semaphore_gather(
269 |         *[
270 |             search(
271 |                 clients,
272 |                 extracted_edge.fact,
273 |                 group_ids=[extracted_edge.group_id],
274 |                 config=EDGE_HYBRID_SEARCH_RRF,
275 |                 search_filter=SearchFilters(),
276 |             )
277 |             for extracted_edge in extracted_edges
278 |         ]
279 |     )
280 | 
281 |     edge_invalidation_candidates: list[list[EntityEdge]] = [
282 |         result.edges for result in edge_invalidation_candidate_results
283 |     ]
284 | 
285 |     logger.debug(
286 |         f'Related edges lists: {[(e.name, e.uuid) for edges_lst in related_edges_lists for e in edges_lst]}'
287 |     )
288 | 
289 |     # Build entity hash table
290 |     uuid_entity_map: dict[str, EntityNode] = {entity.uuid: entity for entity in entities}
291 | 
292 |     # Collect all node UUIDs referenced by edges that are not in the entities list
293 |     referenced_node_uuids = set()
294 |     for extracted_edge in extracted_edges:
295 |         if extracted_edge.source_node_uuid not in uuid_entity_map:
296 |             referenced_node_uuids.add(extracted_edge.source_node_uuid)
297 |         if extracted_edge.target_node_uuid not in uuid_entity_map:
298 |             referenced_node_uuids.add(extracted_edge.target_node_uuid)
299 | 
300 |     # Fetch missing nodes from the database
301 |     if referenced_node_uuids:
302 |         missing_nodes = await EntityNode.get_by_uuids(driver, list(referenced_node_uuids))
303 |         for node in missing_nodes:
304 |             uuid_entity_map[node.uuid] = node
305 | 
306 |     # Determine which edge types are relevant for each edge.
307 |     # `edge_types_lst` stores the subset of custom edge definitions whose
308 |     # node signature matches each extracted edge. Anything outside this subset
309 |     # should only stay on the edge if it is a non-custom (LLM generated) label.
310 |     edge_types_lst: list[dict[str, type[BaseModel]]] = []
311 |     custom_type_names = set(edge_types or {})
312 |     for extracted_edge in extracted_edges:
313 |         source_node = uuid_entity_map.get(extracted_edge.source_node_uuid)
314 |         target_node = uuid_entity_map.get(extracted_edge.target_node_uuid)
315 |         source_node_labels = (
316 |             source_node.labels + ['Entity'] if source_node is not None else ['Entity']
317 |         )
318 |         target_node_labels = (
319 |             target_node.labels + ['Entity'] if target_node is not None else ['Entity']
320 |         )
321 |         label_tuples = [
322 |             (source_label, target_label)
323 |             for source_label in source_node_labels
324 |             for target_label in target_node_labels
325 |         ]
326 | 
327 |         extracted_edge_types = {}
328 |         for label_tuple in label_tuples:
329 |             type_names = edge_type_map.get(label_tuple, [])
330 |             for type_name in type_names:
331 |                 type_model = edge_types.get(type_name)
332 |                 if type_model is None:
333 |                     continue
334 | 
335 |                 extracted_edge_types[type_name] = type_model
336 | 
337 |         edge_types_lst.append(extracted_edge_types)
338 | 
339 |     for extracted_edge, extracted_edge_types in zip(extracted_edges, edge_types_lst, strict=True):
340 |         allowed_type_names = set(extracted_edge_types)
341 |         is_custom_name = extracted_edge.name in custom_type_names
342 |         if not allowed_type_names:
343 |             # No custom types are valid for this node pairing. Keep LLM generated
344 |             # labels, but flip disallowed custom names back to the default.
345 |             if is_custom_name and extracted_edge.name != DEFAULT_EDGE_NAME:
346 |                 extracted_edge.name = DEFAULT_EDGE_NAME
347 |             continue
348 |         if is_custom_name and extracted_edge.name not in allowed_type_names:
349 |             # Custom name exists but it is not permitted for this source/target
350 |             # signature, so fall back to the default edge label.
351 |             extracted_edge.name = DEFAULT_EDGE_NAME
352 | 
353 |     # resolve edges with related edges in the graph and find invalidation candidates
354 |     results: list[tuple[EntityEdge, list[EntityEdge], list[EntityEdge]]] = list(
355 |         await semaphore_gather(
356 |             *[
357 |                 resolve_extracted_edge(
358 |                     llm_client,
359 |                     extracted_edge,
360 |                     related_edges,
361 |                     existing_edges,
362 |                     episode,
363 |                     extracted_edge_types,
364 |                     custom_type_names,
365 |                 )
366 |                 for extracted_edge, related_edges, existing_edges, extracted_edge_types in zip(
367 |                     extracted_edges,
368 |                     related_edges_lists,
369 |                     edge_invalidation_candidates,
370 |                     edge_types_lst,
371 |                     strict=True,
372 |                 )
373 |             ]
374 |         )
375 |     )
376 | 
377 |     resolved_edges: list[EntityEdge] = []
378 |     invalidated_edges: list[EntityEdge] = []
379 |     for result in results:
380 |         resolved_edge = result[0]
381 |         invalidated_edge_chunk = result[1]
382 | 
383 |         resolved_edges.append(resolved_edge)
384 |         invalidated_edges.extend(invalidated_edge_chunk)
385 | 
386 |     logger.debug(f'Resolved edges: {[(e.name, e.uuid) for e in resolved_edges]}')
387 | 
388 |     await semaphore_gather(
389 |         create_entity_edge_embeddings(embedder, resolved_edges),
390 |         create_entity_edge_embeddings(embedder, invalidated_edges),
391 |     )
392 | 
393 |     return resolved_edges, invalidated_edges
394 | 
395 | 
396 | def resolve_edge_contradictions(
397 |     resolved_edge: EntityEdge, invalidation_candidates: list[EntityEdge]
398 | ) -> list[EntityEdge]:
399 |     if len(invalidation_candidates) == 0:
400 |         return []
401 | 
402 |     # Determine which contradictory edges need to be expired
403 |     invalidated_edges: list[EntityEdge] = []
404 |     for edge in invalidation_candidates:
405 |         # (Edge invalid before new edge becomes valid) or (new edge invalid before edge becomes valid)
406 |         edge_invalid_at_utc = ensure_utc(edge.invalid_at)
407 |         resolved_edge_valid_at_utc = ensure_utc(resolved_edge.valid_at)
408 |         edge_valid_at_utc = ensure_utc(edge.valid_at)
409 |         resolved_edge_invalid_at_utc = ensure_utc(resolved_edge.invalid_at)
410 | 
411 |         if (
412 |             edge_invalid_at_utc is not None
413 |             and resolved_edge_valid_at_utc is not None
414 |             and edge_invalid_at_utc <= resolved_edge_valid_at_utc
415 |         ) or (
416 |             edge_valid_at_utc is not None
417 |             and resolved_edge_invalid_at_utc is not None
418 |             and resolved_edge_invalid_at_utc <= edge_valid_at_utc
419 |         ):
420 |             continue
421 |         # New edge invalidates edge
422 |         elif (
423 |             edge_valid_at_utc is not None
424 |             and resolved_edge_valid_at_utc is not None
425 |             and edge_valid_at_utc < resolved_edge_valid_at_utc
426 |         ):
427 |             edge.invalid_at = resolved_edge.valid_at
428 |             edge.expired_at = edge.expired_at if edge.expired_at is not None else utc_now()
429 |             invalidated_edges.append(edge)
430 | 
431 |     return invalidated_edges
432 | 
433 | 
434 | async def resolve_extracted_edge(
435 |     llm_client: LLMClient,
436 |     extracted_edge: EntityEdge,
437 |     related_edges: list[EntityEdge],
438 |     existing_edges: list[EntityEdge],
439 |     episode: EpisodicNode,
440 |     edge_type_candidates: dict[str, type[BaseModel]] | None = None,
441 |     custom_edge_type_names: set[str] | None = None,
442 | ) -> tuple[EntityEdge, list[EntityEdge], list[EntityEdge]]:
443 |     """Resolve an extracted edge against existing graph context.
444 | 
445 |     Parameters
446 |     ----------
447 |     llm_client : LLMClient
448 |         Client used to invoke the LLM for deduplication and attribute extraction.
449 |     extracted_edge : EntityEdge
450 |         Newly extracted edge whose canonical representation is being resolved.
451 |     related_edges : list[EntityEdge]
452 |         Candidate edges with identical endpoints used for duplicate detection.
453 |     existing_edges : list[EntityEdge]
454 |         Broader set of edges evaluated for contradiction / invalidation.
455 |     episode : EpisodicNode
456 |         Episode providing content context when extracting edge attributes.
457 |     edge_type_candidates : dict[str, type[BaseModel]] | None
458 |         Custom edge types permitted for the current source/target signature.
459 |     custom_edge_type_names : set[str] | None
460 |         Full catalog of registered custom edge names. Used to distinguish
461 |         between disallowed custom types (which fall back to the default label)
462 |         and ad-hoc labels emitted by the LLM.
463 | 
464 |     Returns
465 |     -------
466 |     tuple[EntityEdge, list[EntityEdge], list[EntityEdge]]
467 |         The resolved edge, any duplicates, and edges to invalidate.
468 |     """
469 |     if len(related_edges) == 0 and len(existing_edges) == 0:
470 |         return extracted_edge, [], []
471 | 
472 |     # Fast path: if the fact text and endpoints already exist verbatim, reuse the matching edge.
473 |     normalized_fact = _normalize_string_exact(extracted_edge.fact)
474 |     for edge in related_edges:
475 |         if (
476 |             edge.source_node_uuid == extracted_edge.source_node_uuid
477 |             and edge.target_node_uuid == extracted_edge.target_node_uuid
478 |             and _normalize_string_exact(edge.fact) == normalized_fact
479 |         ):
480 |             resolved = edge
481 |             if episode is not None and episode.uuid not in resolved.episodes:
482 |                 resolved.episodes.append(episode.uuid)
483 |             return resolved, [], []
484 | 
485 |     start = time()
486 | 
487 |     # Prepare context for LLM
488 |     related_edges_context = [{'idx': i, 'fact': edge.fact} for i, edge in enumerate(related_edges)]
489 | 
490 |     invalidation_edge_candidates_context = [
491 |         {'idx': i, 'fact': existing_edge.fact} for i, existing_edge in enumerate(existing_edges)
492 |     ]
493 | 
494 |     edge_types_context = (
495 |         [
496 |             {
497 |                 'fact_type_name': type_name,
498 |                 'fact_type_description': type_model.__doc__,
499 |             }
500 |             for type_name, type_model in edge_type_candidates.items()
501 |         ]
502 |         if edge_type_candidates is not None
503 |         else []
504 |     )
505 | 
506 |     context = {
507 |         'existing_edges': related_edges_context,
508 |         'new_edge': extracted_edge.fact,
509 |         'edge_invalidation_candidates': invalidation_edge_candidates_context,
510 |         'edge_types': edge_types_context,
511 |     }
512 | 
513 |     if related_edges or existing_edges:
514 |         logger.debug(
515 |             'Resolving edge: sent %d EXISTING FACTS%s and %d INVALIDATION CANDIDATES%s',
516 |             len(related_edges),
517 |             f' (idx 0-{len(related_edges) - 1})' if related_edges else '',
518 |             len(existing_edges),
519 |             f' (idx 0-{len(existing_edges) - 1})' if existing_edges else '',
520 |         )
521 | 
522 |     llm_response = await llm_client.generate_response(
523 |         prompt_library.dedupe_edges.resolve_edge(context),
524 |         response_model=EdgeDuplicate,
525 |         model_size=ModelSize.small,
526 |         prompt_name='dedupe_edges.resolve_edge',
527 |     )
528 |     response_object = EdgeDuplicate(**llm_response)
529 |     duplicate_facts = response_object.duplicate_facts
530 | 
531 |     # Validate duplicate_facts are in valid range for EXISTING FACTS
532 |     invalid_duplicates = [i for i in duplicate_facts if i < 0 or i >= len(related_edges)]
533 |     if invalid_duplicates:
534 |         logger.warning(
535 |             'LLM returned invalid duplicate_facts idx values %s (valid range: 0-%d for EXISTING FACTS)',
536 |             invalid_duplicates,
537 |             len(related_edges) - 1,
538 |         )
539 | 
540 |     duplicate_fact_ids: list[int] = [i for i in duplicate_facts if 0 <= i < len(related_edges)]
541 | 
542 |     resolved_edge = extracted_edge
543 |     for duplicate_fact_id in duplicate_fact_ids:
544 |         resolved_edge = related_edges[duplicate_fact_id]
545 |         break
546 | 
547 |     if duplicate_fact_ids and episode is not None:
548 |         resolved_edge.episodes.append(episode.uuid)
549 | 
550 |     contradicted_facts: list[int] = response_object.contradicted_facts
551 | 
552 |     # Validate contradicted_facts are in valid range for INVALIDATION CANDIDATES
553 |     invalid_contradictions = [i for i in contradicted_facts if i < 0 or i >= len(existing_edges)]
554 |     if invalid_contradictions:
555 |         logger.warning(
556 |             'LLM returned invalid contradicted_facts idx values %s (valid range: 0-%d for INVALIDATION CANDIDATES)',
557 |             invalid_contradictions,
558 |             len(existing_edges) - 1,
559 |         )
560 | 
561 |     invalidation_candidates: list[EntityEdge] = [
562 |         existing_edges[i] for i in contradicted_facts if 0 <= i < len(existing_edges)
563 |     ]
564 | 
565 |     fact_type: str = response_object.fact_type
566 |     candidate_type_names = set(edge_type_candidates or {})
567 |     custom_type_names = custom_edge_type_names or set()
568 | 
569 |     is_default_type = fact_type.upper() == 'DEFAULT'
570 |     is_custom_type = fact_type in custom_type_names
571 |     is_allowed_custom_type = fact_type in candidate_type_names
572 | 
573 |     if is_allowed_custom_type:
574 |         # The LLM selected a custom type that is allowed for the node pair.
575 |         # Adopt the custom type and, if needed, extract its structured attributes.
576 |         resolved_edge.name = fact_type
577 | 
578 |         edge_attributes_context = {
579 |             'episode_content': episode.content,
580 |             'reference_time': episode.valid_at,
581 |             'fact': resolved_edge.fact,
582 |         }
583 | 
584 |         edge_model = edge_type_candidates.get(fact_type) if edge_type_candidates else None
585 |         if edge_model is not None and len(edge_model.model_fields) != 0:
586 |             edge_attributes_response = await llm_client.generate_response(
587 |                 prompt_library.extract_edges.extract_attributes(edge_attributes_context),
588 |                 response_model=edge_model,  # type: ignore
589 |                 model_size=ModelSize.small,
590 |                 prompt_name='extract_edges.extract_attributes',
591 |             )
592 | 
593 |             resolved_edge.attributes = edge_attributes_response
594 |     elif not is_default_type and is_custom_type:
595 |         # The LLM picked a custom type that is not allowed for this signature.
596 |         # Reset to the default label and drop any structured attributes.
597 |         resolved_edge.name = DEFAULT_EDGE_NAME
598 |         resolved_edge.attributes = {}
599 |     elif not is_default_type:
600 |         # Non-custom labels are allowed to pass through so long as the LLM does
601 |         # not return the sentinel DEFAULT value.
602 |         resolved_edge.name = fact_type
603 |         resolved_edge.attributes = {}
604 | 
605 |     end = time()
606 |     logger.debug(
607 |         f'Resolved Edge: {extracted_edge.name} is {resolved_edge.name}, in {(end - start) * 1000} ms'
608 |     )
609 | 
610 |     now = utc_now()
611 | 
612 |     if resolved_edge.invalid_at and not resolved_edge.expired_at:
613 |         resolved_edge.expired_at = now
614 | 
615 |     # Determine if the new_edge needs to be expired
616 |     if resolved_edge.expired_at is None:
617 |         invalidation_candidates.sort(key=lambda c: (c.valid_at is None, ensure_utc(c.valid_at)))
618 |         for candidate in invalidation_candidates:
619 |             candidate_valid_at_utc = ensure_utc(candidate.valid_at)
620 |             resolved_edge_valid_at_utc = ensure_utc(resolved_edge.valid_at)
621 |             if (
622 |                 candidate_valid_at_utc is not None
623 |                 and resolved_edge_valid_at_utc is not None
624 |                 and candidate_valid_at_utc > resolved_edge_valid_at_utc
625 |             ):
626 |                 # Expire new edge since we have information about more recent events
627 |                 resolved_edge.invalid_at = candidate.valid_at
628 |                 resolved_edge.expired_at = now
629 |                 break
630 | 
631 |     # Determine which contradictory edges need to be expired
632 |     invalidated_edges: list[EntityEdge] = resolve_edge_contradictions(
633 |         resolved_edge, invalidation_candidates
634 |     )
635 |     duplicate_edges: list[EntityEdge] = [related_edges[idx] for idx in duplicate_fact_ids]
636 | 
637 |     return resolved_edge, invalidated_edges, duplicate_edges
638 | 
639 | 
640 | async def filter_existing_duplicate_of_edges(
641 |     driver: GraphDriver, duplicates_node_tuples: list[tuple[EntityNode, EntityNode]]
642 | ) -> list[tuple[EntityNode, EntityNode]]:
643 |     if not duplicates_node_tuples:
644 |         return []
645 | 
646 |     duplicate_nodes_map = {
647 |         (source.uuid, target.uuid): (source, target) for source, target in duplicates_node_tuples
648 |     }
649 | 
650 |     if driver.provider == GraphProvider.NEPTUNE:
651 |         query: LiteralString = """
652 |             UNWIND $duplicate_node_uuids AS duplicate_tuple
653 |             MATCH (n:Entity {uuid: duplicate_tuple.source})-[r:RELATES_TO {name: 'IS_DUPLICATE_OF'}]->(m:Entity {uuid: duplicate_tuple.target})
654 |             RETURN DISTINCT
655 |                 n.uuid AS source_uuid,
656 |                 m.uuid AS target_uuid
657 |         """
658 | 
659 |         duplicate_nodes = [
660 |             {'source': source.uuid, 'target': target.uuid}
661 |             for source, target in duplicates_node_tuples
662 |         ]
663 | 
664 |         records, _, _ = await driver.execute_query(
665 |             query,
666 |             duplicate_node_uuids=duplicate_nodes,
667 |             routing_='r',
668 |         )
669 |     else:
670 |         if driver.provider == GraphProvider.KUZU:
671 |             query = """
672 |                 UNWIND $duplicate_node_uuids AS duplicate
673 |                 MATCH (n:Entity {uuid: duplicate.src})-[:RELATES_TO]->(e:RelatesToNode_ {name: 'IS_DUPLICATE_OF'})-[:RELATES_TO]->(m:Entity {uuid: duplicate.dst})
674 |                 RETURN DISTINCT
675 |                     n.uuid AS source_uuid,
676 |                     m.uuid AS target_uuid
677 |             """
678 |             duplicate_node_uuids = [{'src': src, 'dst': dst} for src, dst in duplicate_nodes_map]
679 |         else:
680 |             query: LiteralString = """
681 |                 UNWIND $duplicate_node_uuids AS duplicate_tuple
682 |                 MATCH (n:Entity {uuid: duplicate_tuple[0]})-[r:RELATES_TO {name: 'IS_DUPLICATE_OF'}]->(m:Entity {uuid: duplicate_tuple[1]})
683 |                 RETURN DISTINCT
684 |                     n.uuid AS source_uuid,
685 |                     m.uuid AS target_uuid
686 |             """
687 |             duplicate_node_uuids = list(duplicate_nodes_map.keys())
688 | 
689 |         records, _, _ = await driver.execute_query(
690 |             query,
691 |             duplicate_node_uuids=duplicate_node_uuids,
692 |             routing_='r',
693 |         )
694 | 
695 |     # Remove duplicates that already have the IS_DUPLICATE_OF edge
696 |     for record in records:
697 |         duplicate_tuple = (record.get('source_uuid'), record.get('target_uuid'))
698 |         if duplicate_nodes_map.get(duplicate_tuple):
699 |             duplicate_nodes_map.pop(duplicate_tuple)
700 | 
701 |     return list(duplicate_nodes_map.values())
702 | 
```

--------------------------------------------------------------------------------
/graphiti_core/nodes.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Copyright 2024, Zep Software, Inc.
  3 | 
  4 | Licensed under the Apache License, Version 2.0 (the "License");
  5 | you may not use this file except in compliance with the License.
  6 | You may obtain a copy of the License at
  7 | 
  8 |     http://www.apache.org/licenses/LICENSE-2.0
  9 | 
 10 | Unless required by applicable law or agreed to in writing, software
 11 | distributed under the License is distributed on an "AS IS" BASIS,
 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 13 | See the License for the specific language governing permissions and
 14 | limitations under the License.
 15 | """
 16 | 
 17 | import json
 18 | import logging
 19 | from abc import ABC, abstractmethod
 20 | from datetime import datetime
 21 | from enum import Enum
 22 | from time import time
 23 | from typing import Any
 24 | from uuid import uuid4
 25 | 
 26 | from pydantic import BaseModel, Field
 27 | from typing_extensions import LiteralString
 28 | 
 29 | from graphiti_core.driver.driver import (
 30 |     GraphDriver,
 31 |     GraphProvider,
 32 | )
 33 | from graphiti_core.embedder import EmbedderClient
 34 | from graphiti_core.errors import NodeNotFoundError
 35 | from graphiti_core.helpers import parse_db_date
 36 | from graphiti_core.models.nodes.node_db_queries import (
 37 |     COMMUNITY_NODE_RETURN,
 38 |     COMMUNITY_NODE_RETURN_NEPTUNE,
 39 |     EPISODIC_NODE_RETURN,
 40 |     EPISODIC_NODE_RETURN_NEPTUNE,
 41 |     get_community_node_save_query,
 42 |     get_entity_node_return_query,
 43 |     get_entity_node_save_query,
 44 |     get_episode_node_save_query,
 45 | )
 46 | from graphiti_core.utils.datetime_utils import utc_now
 47 | 
 48 | logger = logging.getLogger(__name__)
 49 | 
 50 | 
 51 | class EpisodeType(Enum):
 52 |     """
 53 |     Enumeration of different types of episodes that can be processed.
 54 | 
 55 |     This enum defines the various sources or formats of episodes that the system
 56 |     can handle. It's used to categorize and potentially handle different types
 57 |     of input data differently.
 58 | 
 59 |     Attributes:
 60 |     -----------
 61 |     message : str
 62 |         Represents a standard message-type episode. The content for this type
 63 |         should be formatted as "actor: content". For example, "user: Hello, how are you?"
 64 |         or "assistant: I'm doing well, thank you for asking."
 65 |     json : str
 66 |         Represents an episode containing a JSON string object with structured data.
 67 |     text : str
 68 |         Represents a plain text episode.
 69 |     """
 70 | 
 71 |     message = 'message'
 72 |     json = 'json'
 73 |     text = 'text'
 74 | 
 75 |     @staticmethod
 76 |     def from_str(episode_type: str):
 77 |         if episode_type == 'message':
 78 |             return EpisodeType.message
 79 |         if episode_type == 'json':
 80 |             return EpisodeType.json
 81 |         if episode_type == 'text':
 82 |             return EpisodeType.text
 83 |         logger.error(f'Episode type: {episode_type} not implemented')
 84 |         raise NotImplementedError
 85 | 
 86 | 
 87 | class Node(BaseModel, ABC):
 88 |     uuid: str = Field(default_factory=lambda: str(uuid4()))
 89 |     name: str = Field(description='name of the node')
 90 |     group_id: str = Field(description='partition of the graph')
 91 |     labels: list[str] = Field(default_factory=list)
 92 |     created_at: datetime = Field(default_factory=lambda: utc_now())
 93 | 
 94 |     @abstractmethod
 95 |     async def save(self, driver: GraphDriver): ...
 96 | 
 97 |     async def delete(self, driver: GraphDriver):
 98 |         if driver.graph_operations_interface:
 99 |             return await driver.graph_operations_interface.node_delete(self, driver)
100 | 
101 |         match driver.provider:
102 |             case GraphProvider.NEO4J:
103 |                 records, _, _ = await driver.execute_query(
104 |                     """
105 |                     MATCH (n {uuid: $uuid})
106 |                     WHERE n:Entity OR n:Episodic OR n:Community
107 |                     OPTIONAL MATCH (n)-[r]-()
108 |                     WITH collect(r.uuid) AS edge_uuids, n
109 |                     DETACH DELETE n
110 |                     RETURN edge_uuids
111 |                     """,
112 |                     uuid=self.uuid,
113 |                 )
114 | 
115 |             case GraphProvider.KUZU:
116 |                 for label in ['Episodic', 'Community']:
117 |                     await driver.execute_query(
118 |                         f"""
119 |                         MATCH (n:{label} {{uuid: $uuid}})
120 |                         DETACH DELETE n
121 |                         """,
122 |                         uuid=self.uuid,
123 |                     )
124 |                 # Entity edges are actually nodes in Kuzu, so simple `DETACH DELETE` will not work.
125 |                 # Explicitly delete the "edge" nodes first, then the entity node.
126 |                 await driver.execute_query(
127 |                     """
128 |                     MATCH (n:Entity {uuid: $uuid})-[:RELATES_TO]->(e:RelatesToNode_)
129 |                     DETACH DELETE e
130 |                     """,
131 |                     uuid=self.uuid,
132 |                 )
133 |                 await driver.execute_query(
134 |                     """
135 |                     MATCH (n:Entity {uuid: $uuid})
136 |                     DETACH DELETE n
137 |                     """,
138 |                     uuid=self.uuid,
139 |                 )
140 |             case _:  # FalkorDB, Neptune
141 |                 for label in ['Entity', 'Episodic', 'Community']:
142 |                     await driver.execute_query(
143 |                         f"""
144 |                         MATCH (n:{label} {{uuid: $uuid}})
145 |                         DETACH DELETE n
146 |                         """,
147 |                         uuid=self.uuid,
148 |                     )
149 | 
150 |         logger.debug(f'Deleted Node: {self.uuid}')
151 | 
152 |     def __hash__(self):
153 |         return hash(self.uuid)
154 | 
155 |     def __eq__(self, other):
156 |         if isinstance(other, Node):
157 |             return self.uuid == other.uuid
158 |         return False
159 | 
160 |     @classmethod
161 |     async def delete_by_group_id(cls, driver: GraphDriver, group_id: str, batch_size: int = 100):
162 |         if driver.graph_operations_interface:
163 |             return await driver.graph_operations_interface.node_delete_by_group_id(
164 |                 cls, driver, group_id, batch_size
165 |             )
166 | 
167 |         match driver.provider:
168 |             case GraphProvider.NEO4J:
169 |                 async with driver.session() as session:
170 |                     await session.run(
171 |                         """
172 |                         MATCH (n:Entity|Episodic|Community {group_id: $group_id})
173 |                         CALL (n) {
174 |                             DETACH DELETE n
175 |                         } IN TRANSACTIONS OF $batch_size ROWS
176 |                         """,
177 |                         group_id=group_id,
178 |                         batch_size=batch_size,
179 |                     )
180 | 
181 |             case GraphProvider.KUZU:
182 |                 for label in ['Episodic', 'Community']:
183 |                     await driver.execute_query(
184 |                         f"""
185 |                         MATCH (n:{label} {{group_id: $group_id}})
186 |                         DETACH DELETE n
187 |                         """,
188 |                         group_id=group_id,
189 |                     )
190 |                 # Entity edges are actually nodes in Kuzu, so simple `DETACH DELETE` will not work.
191 |                 # Explicitly delete the "edge" nodes first, then the entity node.
192 |                 await driver.execute_query(
193 |                     """
194 |                     MATCH (n:Entity {group_id: $group_id})-[:RELATES_TO]->(e:RelatesToNode_)
195 |                     DETACH DELETE e
196 |                     """,
197 |                     group_id=group_id,
198 |                 )
199 |                 await driver.execute_query(
200 |                     """
201 |                     MATCH (n:Entity {group_id: $group_id})
202 |                     DETACH DELETE n
203 |                     """,
204 |                     group_id=group_id,
205 |                 )
206 |             case _:  # FalkorDB, Neptune
207 |                 for label in ['Entity', 'Episodic', 'Community']:
208 |                     await driver.execute_query(
209 |                         f"""
210 |                         MATCH (n:{label} {{group_id: $group_id}})
211 |                         DETACH DELETE n
212 |                         """,
213 |                         group_id=group_id,
214 |                     )
215 | 
216 |     @classmethod
217 |     async def delete_by_uuids(cls, driver: GraphDriver, uuids: list[str], batch_size: int = 100):
218 |         if driver.graph_operations_interface:
219 |             return await driver.graph_operations_interface.node_delete_by_uuids(
220 |                 cls, driver, uuids, group_id=None, batch_size=batch_size
221 |             )
222 | 
223 |         match driver.provider:
224 |             case GraphProvider.FALKORDB:
225 |                 for label in ['Entity', 'Episodic', 'Community']:
226 |                     await driver.execute_query(
227 |                         f"""
228 |                         MATCH (n:{label})
229 |                         WHERE n.uuid IN $uuids
230 |                         DETACH DELETE n
231 |                         """,
232 |                         uuids=uuids,
233 |                     )
234 |             case GraphProvider.KUZU:
235 |                 for label in ['Episodic', 'Community']:
236 |                     await driver.execute_query(
237 |                         f"""
238 |                         MATCH (n:{label})
239 |                         WHERE n.uuid IN $uuids
240 |                         DETACH DELETE n
241 |                         """,
242 |                         uuids=uuids,
243 |                     )
244 |                 # Entity edges are actually nodes in Kuzu, so simple `DETACH DELETE` will not work.
245 |                 # Explicitly delete the "edge" nodes first, then the entity node.
246 |                 await driver.execute_query(
247 |                     """
248 |                     MATCH (n:Entity)-[:RELATES_TO]->(e:RelatesToNode_)
249 |                     WHERE n.uuid IN $uuids
250 |                     DETACH DELETE e
251 |                     """,
252 |                     uuids=uuids,
253 |                 )
254 |                 await driver.execute_query(
255 |                     """
256 |                     MATCH (n:Entity)
257 |                     WHERE n.uuid IN $uuids
258 |                     DETACH DELETE n
259 |                     """,
260 |                     uuids=uuids,
261 |                 )
262 |             case _:  # Neo4J, Neptune
263 |                 async with driver.session() as session:
264 |                     # Collect all edge UUIDs before deleting nodes
265 |                     await session.run(
266 |                         """
267 |                         MATCH (n:Entity|Episodic|Community)
268 |                         WHERE n.uuid IN $uuids
269 |                         MATCH (n)-[r]-()
270 |                         RETURN collect(r.uuid) AS edge_uuids
271 |                         """,
272 |                         uuids=uuids,
273 |                     )
274 | 
275 |                     # Now delete the nodes in batches
276 |                     await session.run(
277 |                         """
278 |                         MATCH (n:Entity|Episodic|Community)
279 |                         WHERE n.uuid IN $uuids
280 |                         CALL (n) {
281 |                             DETACH DELETE n
282 |                         } IN TRANSACTIONS OF $batch_size ROWS
283 |                         """,
284 |                         uuids=uuids,
285 |                         batch_size=batch_size,
286 |                     )
287 | 
288 |     @classmethod
289 |     async def get_by_uuid(cls, driver: GraphDriver, uuid: str): ...
290 | 
291 |     @classmethod
292 |     async def get_by_uuids(cls, driver: GraphDriver, uuids: list[str]): ...
293 | 
294 | 
295 | class EpisodicNode(Node):
296 |     source: EpisodeType = Field(description='source type')
297 |     source_description: str = Field(description='description of the data source')
298 |     content: str = Field(description='raw episode data')
299 |     valid_at: datetime = Field(
300 |         description='datetime of when the original document was created',
301 |     )
302 |     entity_edges: list[str] = Field(
303 |         description='list of entity edges referenced in this episode',
304 |         default_factory=list,
305 |     )
306 | 
307 |     async def save(self, driver: GraphDriver):
308 |         if driver.graph_operations_interface:
309 |             return await driver.graph_operations_interface.episodic_node_save(self, driver)
310 | 
311 |         episode_args = {
312 |             'uuid': self.uuid,
313 |             'name': self.name,
314 |             'group_id': self.group_id,
315 |             'source_description': self.source_description,
316 |             'content': self.content,
317 |             'entity_edges': self.entity_edges,
318 |             'created_at': self.created_at,
319 |             'valid_at': self.valid_at,
320 |             'source': self.source.value,
321 |         }
322 | 
323 |         result = await driver.execute_query(
324 |             get_episode_node_save_query(driver.provider), **episode_args
325 |         )
326 | 
327 |         logger.debug(f'Saved Node to Graph: {self.uuid}')
328 | 
329 |         return result
330 | 
331 |     @classmethod
332 |     async def get_by_uuid(cls, driver: GraphDriver, uuid: str):
333 |         records, _, _ = await driver.execute_query(
334 |             """
335 |             MATCH (e:Episodic {uuid: $uuid})
336 |             RETURN
337 |             """
338 |             + (
339 |                 EPISODIC_NODE_RETURN_NEPTUNE
340 |                 if driver.provider == GraphProvider.NEPTUNE
341 |                 else EPISODIC_NODE_RETURN
342 |             ),
343 |             uuid=uuid,
344 |             routing_='r',
345 |         )
346 | 
347 |         episodes = [get_episodic_node_from_record(record) for record in records]
348 | 
349 |         if len(episodes) == 0:
350 |             raise NodeNotFoundError(uuid)
351 | 
352 |         return episodes[0]
353 | 
354 |     @classmethod
355 |     async def get_by_uuids(cls, driver: GraphDriver, uuids: list[str]):
356 |         records, _, _ = await driver.execute_query(
357 |             """
358 |             MATCH (e:Episodic)
359 |             WHERE e.uuid IN $uuids
360 |             RETURN DISTINCT
361 |             """
362 |             + (
363 |                 EPISODIC_NODE_RETURN_NEPTUNE
364 |                 if driver.provider == GraphProvider.NEPTUNE
365 |                 else EPISODIC_NODE_RETURN
366 |             ),
367 |             uuids=uuids,
368 |             routing_='r',
369 |         )
370 | 
371 |         episodes = [get_episodic_node_from_record(record) for record in records]
372 | 
373 |         return episodes
374 | 
375 |     @classmethod
376 |     async def get_by_group_ids(
377 |         cls,
378 |         driver: GraphDriver,
379 |         group_ids: list[str],
380 |         limit: int | None = None,
381 |         uuid_cursor: str | None = None,
382 |     ):
383 |         cursor_query: LiteralString = 'AND e.uuid < $uuid' if uuid_cursor else ''
384 |         limit_query: LiteralString = 'LIMIT $limit' if limit is not None else ''
385 | 
386 |         records, _, _ = await driver.execute_query(
387 |             """
388 |             MATCH (e:Episodic)
389 |             WHERE e.group_id IN $group_ids
390 |             """
391 |             + cursor_query
392 |             + """
393 |             RETURN DISTINCT
394 |             """
395 |             + (
396 |                 EPISODIC_NODE_RETURN_NEPTUNE
397 |                 if driver.provider == GraphProvider.NEPTUNE
398 |                 else EPISODIC_NODE_RETURN
399 |             )
400 |             + """
401 |             ORDER BY uuid DESC
402 |             """
403 |             + limit_query,
404 |             group_ids=group_ids,
405 |             uuid=uuid_cursor,
406 |             limit=limit,
407 |             routing_='r',
408 |         )
409 | 
410 |         episodes = [get_episodic_node_from_record(record) for record in records]
411 | 
412 |         return episodes
413 | 
414 |     @classmethod
415 |     async def get_by_entity_node_uuid(cls, driver: GraphDriver, entity_node_uuid: str):
416 |         records, _, _ = await driver.execute_query(
417 |             """
418 |             MATCH (e:Episodic)-[r:MENTIONS]->(n:Entity {uuid: $entity_node_uuid})
419 |             RETURN DISTINCT
420 |             """
421 |             + (
422 |                 EPISODIC_NODE_RETURN_NEPTUNE
423 |                 if driver.provider == GraphProvider.NEPTUNE
424 |                 else EPISODIC_NODE_RETURN
425 |             ),
426 |             entity_node_uuid=entity_node_uuid,
427 |             routing_='r',
428 |         )
429 | 
430 |         episodes = [get_episodic_node_from_record(record) for record in records]
431 | 
432 |         return episodes
433 | 
434 | 
435 | class EntityNode(Node):
436 |     name_embedding: list[float] | None = Field(default=None, description='embedding of the name')
437 |     summary: str = Field(description='regional summary of surrounding edges', default_factory=str)
438 |     attributes: dict[str, Any] = Field(
439 |         default={}, description='Additional attributes of the node. Dependent on node labels'
440 |     )
441 | 
442 |     async def generate_name_embedding(self, embedder: EmbedderClient):
443 |         start = time()
444 |         text = self.name.replace('\n', ' ')
445 |         self.name_embedding = await embedder.create(input_data=[text])
446 |         end = time()
447 |         logger.debug(f'embedded {text} in {end - start} ms')
448 | 
449 |         return self.name_embedding
450 | 
451 |     async def load_name_embedding(self, driver: GraphDriver):
452 |         if driver.graph_operations_interface:
453 |             return await driver.graph_operations_interface.node_load_embeddings(self, driver)
454 | 
455 |         if driver.provider == GraphProvider.NEPTUNE:
456 |             query: LiteralString = """
457 |                 MATCH (n:Entity {uuid: $uuid})
458 |                 RETURN [x IN split(n.name_embedding, ",") | toFloat(x)] as name_embedding
459 |             """
460 | 
461 |         else:
462 |             query: LiteralString = """
463 |                 MATCH (n:Entity {uuid: $uuid})
464 |                 RETURN n.name_embedding AS name_embedding
465 |             """
466 |         records, _, _ = await driver.execute_query(
467 |             query,
468 |             uuid=self.uuid,
469 |             routing_='r',
470 |         )
471 | 
472 |         if len(records) == 0:
473 |             raise NodeNotFoundError(self.uuid)
474 | 
475 |         self.name_embedding = records[0]['name_embedding']
476 | 
477 |     async def save(self, driver: GraphDriver):
478 |         if driver.graph_operations_interface:
479 |             return await driver.graph_operations_interface.node_save(self, driver)
480 | 
481 |         entity_data: dict[str, Any] = {
482 |             'uuid': self.uuid,
483 |             'name': self.name,
484 |             'name_embedding': self.name_embedding,
485 |             'group_id': self.group_id,
486 |             'summary': self.summary,
487 |             'created_at': self.created_at,
488 |         }
489 | 
490 |         if driver.provider == GraphProvider.KUZU:
491 |             entity_data['attributes'] = json.dumps(self.attributes)
492 |             entity_data['labels'] = list(set(self.labels + ['Entity']))
493 |             result = await driver.execute_query(
494 |                 get_entity_node_save_query(driver.provider, labels=''),
495 |                 **entity_data,
496 |             )
497 |         else:
498 |             entity_data.update(self.attributes or {})
499 |             labels = ':'.join(self.labels + ['Entity'])
500 | 
501 |             result = await driver.execute_query(
502 |                 get_entity_node_save_query(driver.provider, labels),
503 |                 entity_data=entity_data,
504 |             )
505 | 
506 |         logger.debug(f'Saved Node to Graph: {self.uuid}')
507 | 
508 |         return result
509 | 
510 |     @classmethod
511 |     async def get_by_uuid(cls, driver: GraphDriver, uuid: str):
512 |         records, _, _ = await driver.execute_query(
513 |             """
514 |             MATCH (n:Entity {uuid: $uuid})
515 |             RETURN
516 |             """
517 |             + get_entity_node_return_query(driver.provider),
518 |             uuid=uuid,
519 |             routing_='r',
520 |         )
521 | 
522 |         nodes = [get_entity_node_from_record(record, driver.provider) for record in records]
523 | 
524 |         if len(nodes) == 0:
525 |             raise NodeNotFoundError(uuid)
526 | 
527 |         return nodes[0]
528 | 
529 |     @classmethod
530 |     async def get_by_uuids(cls, driver: GraphDriver, uuids: list[str]):
531 |         records, _, _ = await driver.execute_query(
532 |             """
533 |             MATCH (n:Entity)
534 |             WHERE n.uuid IN $uuids
535 |             RETURN
536 |             """
537 |             + get_entity_node_return_query(driver.provider),
538 |             uuids=uuids,
539 |             routing_='r',
540 |         )
541 | 
542 |         nodes = [get_entity_node_from_record(record, driver.provider) for record in records]
543 | 
544 |         return nodes
545 | 
546 |     @classmethod
547 |     async def get_by_group_ids(
548 |         cls,
549 |         driver: GraphDriver,
550 |         group_ids: list[str],
551 |         limit: int | None = None,
552 |         uuid_cursor: str | None = None,
553 |         with_embeddings: bool = False,
554 |     ):
555 |         cursor_query: LiteralString = 'AND n.uuid < $uuid' if uuid_cursor else ''
556 |         limit_query: LiteralString = 'LIMIT $limit' if limit is not None else ''
557 |         with_embeddings_query: LiteralString = (
558 |             """,
559 |             n.name_embedding AS name_embedding
560 |             """
561 |             if with_embeddings
562 |             else ''
563 |         )
564 | 
565 |         records, _, _ = await driver.execute_query(
566 |             """
567 |             MATCH (n:Entity)
568 |             WHERE n.group_id IN $group_ids
569 |             """
570 |             + cursor_query
571 |             + """
572 |             RETURN
573 |             """
574 |             + get_entity_node_return_query(driver.provider)
575 |             + with_embeddings_query
576 |             + """
577 |             ORDER BY n.uuid DESC
578 |             """
579 |             + limit_query,
580 |             group_ids=group_ids,
581 |             uuid=uuid_cursor,
582 |             limit=limit,
583 |             routing_='r',
584 |         )
585 | 
586 |         nodes = [get_entity_node_from_record(record, driver.provider) for record in records]
587 | 
588 |         return nodes
589 | 
590 | 
591 | class CommunityNode(Node):
592 |     name_embedding: list[float] | None = Field(default=None, description='embedding of the name')
593 |     summary: str = Field(description='region summary of member nodes', default_factory=str)
594 | 
595 |     async def save(self, driver: GraphDriver):
596 |         if driver.provider == GraphProvider.NEPTUNE:
597 |             await driver.save_to_aoss(  # pyright: ignore reportAttributeAccessIssue
598 |                 'communities',
599 |                 [{'name': self.name, 'uuid': self.uuid, 'group_id': self.group_id}],
600 |             )
601 |         result = await driver.execute_query(
602 |             get_community_node_save_query(driver.provider),  # type: ignore
603 |             uuid=self.uuid,
604 |             name=self.name,
605 |             group_id=self.group_id,
606 |             summary=self.summary,
607 |             name_embedding=self.name_embedding,
608 |             created_at=self.created_at,
609 |         )
610 | 
611 |         logger.debug(f'Saved Node to Graph: {self.uuid}')
612 | 
613 |         return result
614 | 
615 |     async def generate_name_embedding(self, embedder: EmbedderClient):
616 |         start = time()
617 |         text = self.name.replace('\n', ' ')
618 |         self.name_embedding = await embedder.create(input_data=[text])
619 |         end = time()
620 |         logger.debug(f'embedded {text} in {end - start} ms')
621 | 
622 |         return self.name_embedding
623 | 
624 |     async def load_name_embedding(self, driver: GraphDriver):
625 |         if driver.provider == GraphProvider.NEPTUNE:
626 |             query: LiteralString = """
627 |                 MATCH (c:Community {uuid: $uuid})
628 |                 RETURN [x IN split(c.name_embedding, ",") | toFloat(x)] as name_embedding
629 |             """
630 |         else:
631 |             query: LiteralString = """
632 |             MATCH (c:Community {uuid: $uuid})
633 |             RETURN c.name_embedding AS name_embedding
634 |             """
635 | 
636 |         records, _, _ = await driver.execute_query(
637 |             query,
638 |             uuid=self.uuid,
639 |             routing_='r',
640 |         )
641 | 
642 |         if len(records) == 0:
643 |             raise NodeNotFoundError(self.uuid)
644 | 
645 |         self.name_embedding = records[0]['name_embedding']
646 | 
647 |     @classmethod
648 |     async def get_by_uuid(cls, driver: GraphDriver, uuid: str):
649 |         records, _, _ = await driver.execute_query(
650 |             """
651 |             MATCH (c:Community {uuid: $uuid})
652 |             RETURN
653 |             """
654 |             + (
655 |                 COMMUNITY_NODE_RETURN_NEPTUNE
656 |                 if driver.provider == GraphProvider.NEPTUNE
657 |                 else COMMUNITY_NODE_RETURN
658 |             ),
659 |             uuid=uuid,
660 |             routing_='r',
661 |         )
662 | 
663 |         nodes = [get_community_node_from_record(record) for record in records]
664 | 
665 |         if len(nodes) == 0:
666 |             raise NodeNotFoundError(uuid)
667 | 
668 |         return nodes[0]
669 | 
670 |     @classmethod
671 |     async def get_by_uuids(cls, driver: GraphDriver, uuids: list[str]):
672 |         records, _, _ = await driver.execute_query(
673 |             """
674 |             MATCH (c:Community)
675 |             WHERE c.uuid IN $uuids
676 |             RETURN
677 |             """
678 |             + (
679 |                 COMMUNITY_NODE_RETURN_NEPTUNE
680 |                 if driver.provider == GraphProvider.NEPTUNE
681 |                 else COMMUNITY_NODE_RETURN
682 |             ),
683 |             uuids=uuids,
684 |             routing_='r',
685 |         )
686 | 
687 |         communities = [get_community_node_from_record(record) for record in records]
688 | 
689 |         return communities
690 | 
691 |     @classmethod
692 |     async def get_by_group_ids(
693 |         cls,
694 |         driver: GraphDriver,
695 |         group_ids: list[str],
696 |         limit: int | None = None,
697 |         uuid_cursor: str | None = None,
698 |     ):
699 |         cursor_query: LiteralString = 'AND c.uuid < $uuid' if uuid_cursor else ''
700 |         limit_query: LiteralString = 'LIMIT $limit' if limit is not None else ''
701 | 
702 |         records, _, _ = await driver.execute_query(
703 |             """
704 |             MATCH (c:Community)
705 |             WHERE c.group_id IN $group_ids
706 |             """
707 |             + cursor_query
708 |             + """
709 |             RETURN
710 |             """
711 |             + (
712 |                 COMMUNITY_NODE_RETURN_NEPTUNE
713 |                 if driver.provider == GraphProvider.NEPTUNE
714 |                 else COMMUNITY_NODE_RETURN
715 |             )
716 |             + """
717 |             ORDER BY c.uuid DESC
718 |             """
719 |             + limit_query,
720 |             group_ids=group_ids,
721 |             uuid=uuid_cursor,
722 |             limit=limit,
723 |             routing_='r',
724 |         )
725 | 
726 |         communities = [get_community_node_from_record(record) for record in records]
727 | 
728 |         return communities
729 | 
730 | 
731 | # Node helpers
732 | def get_episodic_node_from_record(record: Any) -> EpisodicNode:
733 |     created_at = parse_db_date(record['created_at'])
734 |     valid_at = parse_db_date(record['valid_at'])
735 | 
736 |     if created_at is None:
737 |         raise ValueError(f'created_at cannot be None for episode {record.get("uuid", "unknown")}')
738 |     if valid_at is None:
739 |         raise ValueError(f'valid_at cannot be None for episode {record.get("uuid", "unknown")}')
740 | 
741 |     return EpisodicNode(
742 |         content=record['content'],
743 |         created_at=created_at,
744 |         valid_at=valid_at,
745 |         uuid=record['uuid'],
746 |         group_id=record['group_id'],
747 |         source=EpisodeType.from_str(record['source']),
748 |         name=record['name'],
749 |         source_description=record['source_description'],
750 |         entity_edges=record['entity_edges'],
751 |     )
752 | 
753 | 
754 | def get_entity_node_from_record(record: Any, provider: GraphProvider) -> EntityNode:
755 |     if provider == GraphProvider.KUZU:
756 |         attributes = json.loads(record['attributes']) if record['attributes'] else {}
757 |     else:
758 |         attributes = record['attributes']
759 |         attributes.pop('uuid', None)
760 |         attributes.pop('name', None)
761 |         attributes.pop('group_id', None)
762 |         attributes.pop('name_embedding', None)
763 |         attributes.pop('summary', None)
764 |         attributes.pop('created_at', None)
765 |         attributes.pop('labels', None)
766 | 
767 |     labels = record.get('labels', [])
768 |     group_id = record.get('group_id')
769 |     if 'Entity_' + group_id.replace('-', '') in labels:
770 |         labels.remove('Entity_' + group_id.replace('-', ''))
771 | 
772 |     entity_node = EntityNode(
773 |         uuid=record['uuid'],
774 |         name=record['name'],
775 |         name_embedding=record.get('name_embedding'),
776 |         group_id=group_id,
777 |         labels=labels,
778 |         created_at=parse_db_date(record['created_at']),  # type: ignore
779 |         summary=record['summary'],
780 |         attributes=attributes,
781 |     )
782 | 
783 |     return entity_node
784 | 
785 | 
786 | def get_community_node_from_record(record: Any) -> CommunityNode:
787 |     return CommunityNode(
788 |         uuid=record['uuid'],
789 |         name=record['name'],
790 |         group_id=record['group_id'],
791 |         name_embedding=record['name_embedding'],
792 |         created_at=parse_db_date(record['created_at']),  # type: ignore
793 |         summary=record['summary'],
794 |     )
795 | 
796 | 
797 | async def create_entity_node_embeddings(embedder: EmbedderClient, nodes: list[EntityNode]):
798 |     # filter out falsey values from nodes
799 |     filtered_nodes = [node for node in nodes if node.name]
800 | 
801 |     if not filtered_nodes:
802 |         return
803 | 
804 |     name_embeddings = await embedder.create_batch([node.name for node in filtered_nodes])
805 |     for node, name_embedding in zip(filtered_nodes, name_embeddings, strict=True):
806 |         node.name_embedding = name_embedding
807 | 
```

--------------------------------------------------------------------------------
/mcp_server/src/graphiti_mcp_server.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """
  3 | Graphiti MCP Server - Exposes Graphiti functionality through the Model Context Protocol (MCP)
  4 | """
  5 | 
  6 | import argparse
  7 | import asyncio
  8 | import logging
  9 | import os
 10 | import sys
 11 | from pathlib import Path
 12 | from typing import Any, Optional
 13 | 
 14 | from dotenv import load_dotenv
 15 | from graphiti_core import Graphiti
 16 | from graphiti_core.edges import EntityEdge
 17 | from graphiti_core.nodes import EpisodeType, EpisodicNode
 18 | from graphiti_core.search.search_filters import SearchFilters
 19 | from graphiti_core.utils.maintenance.graph_data_operations import clear_data
 20 | from mcp.server.fastmcp import FastMCP
 21 | from pydantic import BaseModel
 22 | from starlette.responses import JSONResponse
 23 | 
 24 | from config.schema import GraphitiConfig, ServerConfig
 25 | from models.response_types import (
 26 |     EpisodeSearchResponse,
 27 |     ErrorResponse,
 28 |     FactSearchResponse,
 29 |     NodeResult,
 30 |     NodeSearchResponse,
 31 |     StatusResponse,
 32 |     SuccessResponse,
 33 | )
 34 | from services.factories import DatabaseDriverFactory, EmbedderFactory, LLMClientFactory
 35 | from services.queue_service import QueueService
 36 | from utils.formatting import format_fact_result
 37 | 
 38 | # Load .env file from mcp_server directory
 39 | mcp_server_dir = Path(__file__).parent.parent
 40 | env_file = mcp_server_dir / '.env'
 41 | if env_file.exists():
 42 |     load_dotenv(env_file)
 43 | else:
 44 |     # Try current working directory as fallback
 45 |     load_dotenv()
 46 | 
 47 | 
 48 | # Semaphore limit for concurrent Graphiti operations.
 49 | #
 50 | # This controls how many episodes can be processed simultaneously. Each episode
 51 | # processing involves multiple LLM calls (entity extraction, deduplication, etc.),
 52 | # so the actual number of concurrent LLM requests will be higher.
 53 | #
 54 | # TUNING GUIDELINES:
 55 | #
 56 | # LLM Provider Rate Limits (requests per minute):
 57 | # - OpenAI Tier 1 (free):     3 RPM   -> SEMAPHORE_LIMIT=1-2
 58 | # - OpenAI Tier 2:            60 RPM   -> SEMAPHORE_LIMIT=5-8
 59 | # - OpenAI Tier 3:           500 RPM   -> SEMAPHORE_LIMIT=10-15
 60 | # - OpenAI Tier 4:         5,000 RPM   -> SEMAPHORE_LIMIT=20-50
 61 | # - Anthropic (default):     50 RPM   -> SEMAPHORE_LIMIT=5-8
 62 | # - Anthropic (high tier): 1,000 RPM   -> SEMAPHORE_LIMIT=15-30
 63 | # - Azure OpenAI (varies):  Consult your quota -> adjust accordingly
 64 | #
 65 | # SYMPTOMS:
 66 | # - Too high: 429 rate limit errors, increased costs from parallel processing
 67 | # - Too low: Slow throughput, underutilized API quota
 68 | #
 69 | # MONITORING:
 70 | # - Watch logs for rate limit errors (429)
 71 | # - Monitor episode processing times
 72 | # - Check LLM provider dashboard for actual request rates
 73 | #
 74 | # DEFAULT: 10 (suitable for OpenAI Tier 3, mid-tier Anthropic)
 75 | SEMAPHORE_LIMIT = int(os.getenv('SEMAPHORE_LIMIT', 10))
 76 | 
 77 | 
 78 | # Configure structured logging with timestamps
 79 | LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
 80 | DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
 81 | 
 82 | logging.basicConfig(
 83 |     level=logging.INFO,
 84 |     format=LOG_FORMAT,
 85 |     datefmt=DATE_FORMAT,
 86 |     stream=sys.stderr,
 87 | )
 88 | 
 89 | # Configure specific loggers
 90 | logging.getLogger('uvicorn').setLevel(logging.INFO)
 91 | logging.getLogger('uvicorn.access').setLevel(logging.WARNING)  # Reduce access log noise
 92 | logging.getLogger('mcp.server.streamable_http_manager').setLevel(
 93 |     logging.WARNING
 94 | )  # Reduce MCP noise
 95 | 
 96 | 
 97 | # Patch uvicorn's logging config to use our format
 98 | def configure_uvicorn_logging():
 99 |     """Configure uvicorn loggers to match our format after they're created."""
100 |     for logger_name in ['uvicorn', 'uvicorn.error', 'uvicorn.access']:
101 |         uvicorn_logger = logging.getLogger(logger_name)
102 |         # Remove existing handlers and add our own with proper formatting
103 |         uvicorn_logger.handlers.clear()
104 |         handler = logging.StreamHandler(sys.stderr)
105 |         handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
106 |         uvicorn_logger.addHandler(handler)
107 |         uvicorn_logger.propagate = False
108 | 
109 | 
110 | logger = logging.getLogger(__name__)
111 | 
112 | # Create global config instance - will be properly initialized later
113 | config: GraphitiConfig
114 | 
115 | # MCP server instructions
116 | GRAPHITI_MCP_INSTRUCTIONS = """
117 | Graphiti is a memory service for AI agents built on a knowledge graph. Graphiti performs well
118 | with dynamic data such as user interactions, changing enterprise data, and external information.
119 | 
120 | Graphiti transforms information into a richly connected knowledge network, allowing you to 
121 | capture relationships between concepts, entities, and information. The system organizes data as episodes 
122 | (content snippets), nodes (entities), and facts (relationships between entities), creating a dynamic, 
123 | queryable memory store that evolves with new information. Graphiti supports multiple data formats, including 
124 | structured JSON data, enabling seamless integration with existing data pipelines and systems.
125 | 
126 | Facts contain temporal metadata, allowing you to track the time of creation and whether a fact is invalid 
127 | (superseded by new information).
128 | 
129 | Key capabilities:
130 | 1. Add episodes (text, messages, or JSON) to the knowledge graph with the add_memory tool
131 | 2. Search for nodes (entities) in the graph using natural language queries with search_nodes
132 | 3. Find relevant facts (relationships between entities) with search_facts
133 | 4. Retrieve specific entity edges or episodes by UUID
134 | 5. Manage the knowledge graph with tools like delete_episode, delete_entity_edge, and clear_graph
135 | 
136 | The server connects to a database for persistent storage and uses language models for certain operations. 
137 | Each piece of information is organized by group_id, allowing you to maintain separate knowledge domains.
138 | 
139 | When adding information, provide descriptive names and detailed content to improve search quality. 
140 | When searching, use specific queries and consider filtering by group_id for more relevant results.
141 | 
142 | For optimal performance, ensure the database is properly configured and accessible, and valid 
143 | API keys are provided for any language model operations.
144 | """
145 | 
146 | # MCP server instance
147 | mcp = FastMCP(
148 |     'Graphiti Agent Memory',
149 |     instructions=GRAPHITI_MCP_INSTRUCTIONS,
150 | )
151 | 
152 | # Global services
153 | graphiti_service: Optional['GraphitiService'] = None
154 | queue_service: QueueService | None = None
155 | 
156 | # Global client for backward compatibility
157 | graphiti_client: Graphiti | None = None
158 | semaphore: asyncio.Semaphore
159 | 
160 | 
161 | class GraphitiService:
162 |     """Graphiti service using the unified configuration system."""
163 | 
164 |     def __init__(self, config: GraphitiConfig, semaphore_limit: int = 10):
165 |         self.config = config
166 |         self.semaphore_limit = semaphore_limit
167 |         self.semaphore = asyncio.Semaphore(semaphore_limit)
168 |         self.client: Graphiti | None = None
169 |         self.entity_types = None
170 | 
171 |     async def initialize(self) -> None:
172 |         """Initialize the Graphiti client with factory-created components."""
173 |         try:
174 |             # Create clients using factories
175 |             llm_client = None
176 |             embedder_client = None
177 | 
178 |             # Create LLM client based on configured provider
179 |             try:
180 |                 llm_client = LLMClientFactory.create(self.config.llm)
181 |             except Exception as e:
182 |                 logger.warning(f'Failed to create LLM client: {e}')
183 | 
184 |             # Create embedder client based on configured provider
185 |             try:
186 |                 embedder_client = EmbedderFactory.create(self.config.embedder)
187 |             except Exception as e:
188 |                 logger.warning(f'Failed to create embedder client: {e}')
189 | 
190 |             # Get database configuration
191 |             db_config = DatabaseDriverFactory.create_config(self.config.database)
192 | 
193 |             # Build entity types from configuration
194 |             custom_types = None
195 |             if self.config.graphiti.entity_types:
196 |                 custom_types = {}
197 |                 for entity_type in self.config.graphiti.entity_types:
198 |                     # Create a dynamic Pydantic model for each entity type
199 |                     # Note: Don't use 'name' as it's a protected Pydantic attribute
200 |                     entity_model = type(
201 |                         entity_type.name,
202 |                         (BaseModel,),
203 |                         {
204 |                             '__doc__': entity_type.description,
205 |                         },
206 |                     )
207 |                     custom_types[entity_type.name] = entity_model
208 | 
209 |             # Store entity types for later use
210 |             self.entity_types = custom_types
211 | 
212 |             # Initialize Graphiti client with appropriate driver
213 |             try:
214 |                 if self.config.database.provider.lower() == 'falkordb':
215 |                     # For FalkorDB, create a FalkorDriver instance directly
216 |                     from graphiti_core.driver.falkordb_driver import FalkorDriver
217 | 
218 |                     falkor_driver = FalkorDriver(
219 |                         host=db_config['host'],
220 |                         port=db_config['port'],
221 |                         password=db_config['password'],
222 |                         database=db_config['database'],
223 |                     )
224 | 
225 |                     self.client = Graphiti(
226 |                         graph_driver=falkor_driver,
227 |                         llm_client=llm_client,
228 |                         embedder=embedder_client,
229 |                         max_coroutines=self.semaphore_limit,
230 |                     )
231 |                 else:
232 |                     # For Neo4j (default), use the original approach
233 |                     self.client = Graphiti(
234 |                         uri=db_config['uri'],
235 |                         user=db_config['user'],
236 |                         password=db_config['password'],
237 |                         llm_client=llm_client,
238 |                         embedder=embedder_client,
239 |                         max_coroutines=self.semaphore_limit,
240 |                     )
241 |             except Exception as db_error:
242 |                 # Check for connection errors
243 |                 error_msg = str(db_error).lower()
244 |                 if 'connection refused' in error_msg or 'could not connect' in error_msg:
245 |                     db_provider = self.config.database.provider
246 |                     if db_provider.lower() == 'falkordb':
247 |                         raise RuntimeError(
248 |                             f'\n{"=" * 70}\n'
249 |                             f'Database Connection Error: FalkorDB is not running\n'
250 |                             f'{"=" * 70}\n\n'
251 |                             f'FalkorDB at {db_config["host"]}:{db_config["port"]} is not accessible.\n\n'
252 |                             f'To start FalkorDB:\n'
253 |                             f'  - Using Docker Compose: cd mcp_server && docker compose up\n'
254 |                             f'  - Or run FalkorDB manually: docker run -p 6379:6379 falkordb/falkordb\n\n'
255 |                             f'{"=" * 70}\n'
256 |                         ) from db_error
257 |                     elif db_provider.lower() == 'neo4j':
258 |                         raise RuntimeError(
259 |                             f'\n{"=" * 70}\n'
260 |                             f'Database Connection Error: Neo4j is not running\n'
261 |                             f'{"=" * 70}\n\n'
262 |                             f'Neo4j at {db_config.get("uri", "unknown")} is not accessible.\n\n'
263 |                             f'To start Neo4j:\n'
264 |                             f'  - Using Docker Compose: cd mcp_server && docker compose -f docker/docker-compose-neo4j.yml up\n'
265 |                             f'  - Or install Neo4j Desktop from: https://neo4j.com/download/\n'
266 |                             f'  - Or run Neo4j manually: docker run -p 7474:7474 -p 7687:7687 neo4j:latest\n\n'
267 |                             f'{"=" * 70}\n'
268 |                         ) from db_error
269 |                     else:
270 |                         raise RuntimeError(
271 |                             f'\n{"=" * 70}\n'
272 |                             f'Database Connection Error: {db_provider} is not running\n'
273 |                             f'{"=" * 70}\n\n'
274 |                             f'{db_provider} at {db_config.get("uri", "unknown")} is not accessible.\n\n'
275 |                             f'Please ensure {db_provider} is running and accessible.\n\n'
276 |                             f'{"=" * 70}\n'
277 |                         ) from db_error
278 |                 # Re-raise other errors
279 |                 raise
280 | 
281 |             # Build indices
282 |             await self.client.build_indices_and_constraints()
283 | 
284 |             logger.info('Successfully initialized Graphiti client')
285 | 
286 |             # Log configuration details
287 |             if llm_client:
288 |                 logger.info(
289 |                     f'Using LLM provider: {self.config.llm.provider} / {self.config.llm.model}'
290 |                 )
291 |             else:
292 |                 logger.info('No LLM client configured - entity extraction will be limited')
293 | 
294 |             if embedder_client:
295 |                 logger.info(f'Using Embedder provider: {self.config.embedder.provider}')
296 |             else:
297 |                 logger.info('No Embedder client configured - search will be limited')
298 | 
299 |             if self.entity_types:
300 |                 entity_type_names = list(self.entity_types.keys())
301 |                 logger.info(f'Using custom entity types: {", ".join(entity_type_names)}')
302 |             else:
303 |                 logger.info('Using default entity types')
304 | 
305 |             logger.info(f'Using database: {self.config.database.provider}')
306 |             logger.info(f'Using group_id: {self.config.graphiti.group_id}')
307 | 
308 |         except Exception as e:
309 |             logger.error(f'Failed to initialize Graphiti client: {e}')
310 |             raise
311 | 
312 |     async def get_client(self) -> Graphiti:
313 |         """Get the Graphiti client, initializing if necessary."""
314 |         if self.client is None:
315 |             await self.initialize()
316 |         if self.client is None:
317 |             raise RuntimeError('Failed to initialize Graphiti client')
318 |         return self.client
319 | 
320 | 
321 | @mcp.tool()
322 | async def add_memory(
323 |     name: str,
324 |     episode_body: str,
325 |     group_id: str | None = None,
326 |     source: str = 'text',
327 |     source_description: str = '',
328 |     uuid: str | None = None,
329 | ) -> SuccessResponse | ErrorResponse:
330 |     """Add an episode to memory. This is the primary way to add information to the graph.
331 | 
332 |     This function returns immediately and processes the episode addition in the background.
333 |     Episodes for the same group_id are processed sequentially to avoid race conditions.
334 | 
335 |     Args:
336 |         name (str): Name of the episode
337 |         episode_body (str): The content of the episode to persist to memory. When source='json', this must be a
338 |                            properly escaped JSON string, not a raw Python dictionary. The JSON data will be
339 |                            automatically processed to extract entities and relationships.
340 |         group_id (str, optional): A unique ID for this graph. If not provided, uses the default group_id from CLI
341 |                                  or a generated one.
342 |         source (str, optional): Source type, must be one of:
343 |                                - 'text': For plain text content (default)
344 |                                - 'json': For structured data
345 |                                - 'message': For conversation-style content
346 |         source_description (str, optional): Description of the source
347 |         uuid (str, optional): Optional UUID for the episode
348 | 
349 |     Examples:
350 |         # Adding plain text content
351 |         add_memory(
352 |             name="Company News",
353 |             episode_body="Acme Corp announced a new product line today.",
354 |             source="text",
355 |             source_description="news article",
356 |             group_id="some_arbitrary_string"
357 |         )
358 | 
359 |         # Adding structured JSON data
360 |         # NOTE: episode_body should be a JSON string (standard JSON escaping)
361 |         add_memory(
362 |             name="Customer Profile",
363 |             episode_body='{"company": {"name": "Acme Technologies"}, "products": [{"id": "P001", "name": "CloudSync"}, {"id": "P002", "name": "DataMiner"}]}',
364 |             source="json",
365 |             source_description="CRM data"
366 |         )
367 |     """
368 |     global graphiti_service, queue_service
369 | 
370 |     if graphiti_service is None or queue_service is None:
371 |         return ErrorResponse(error='Services not initialized')
372 | 
373 |     try:
374 |         # Use the provided group_id or fall back to the default from config
375 |         effective_group_id = group_id or config.graphiti.group_id
376 | 
377 |         # Try to parse the source as an EpisodeType enum, with fallback to text
378 |         episode_type = EpisodeType.text  # Default
379 |         if source:
380 |             try:
381 |                 episode_type = EpisodeType[source.lower()]
382 |             except (KeyError, AttributeError):
383 |                 # If the source doesn't match any enum value, use text as default
384 |                 logger.warning(f"Unknown source type '{source}', using 'text' as default")
385 |                 episode_type = EpisodeType.text
386 | 
387 |         # Submit to queue service for async processing
388 |         await queue_service.add_episode(
389 |             group_id=effective_group_id,
390 |             name=name,
391 |             content=episode_body,
392 |             source_description=source_description,
393 |             episode_type=episode_type,
394 |             entity_types=graphiti_service.entity_types,
395 |             uuid=uuid or None,  # Ensure None is passed if uuid is None
396 |         )
397 | 
398 |         return SuccessResponse(
399 |             message=f"Episode '{name}' queued for processing in group '{effective_group_id}'"
400 |         )
401 |     except Exception as e:
402 |         error_msg = str(e)
403 |         logger.error(f'Error queuing episode: {error_msg}')
404 |         return ErrorResponse(error=f'Error queuing episode: {error_msg}')
405 | 
406 | 
407 | @mcp.tool()
408 | async def search_nodes(
409 |     query: str,
410 |     group_ids: list[str] | None = None,
411 |     max_nodes: int = 10,
412 |     entity_types: list[str] | None = None,
413 | ) -> NodeSearchResponse | ErrorResponse:
414 |     """Search for nodes in the graph memory.
415 | 
416 |     Args:
417 |         query: The search query
418 |         group_ids: Optional list of group IDs to filter results
419 |         max_nodes: Maximum number of nodes to return (default: 10)
420 |         entity_types: Optional list of entity type names to filter by
421 |     """
422 |     global graphiti_service
423 | 
424 |     if graphiti_service is None:
425 |         return ErrorResponse(error='Graphiti service not initialized')
426 | 
427 |     try:
428 |         client = await graphiti_service.get_client()
429 | 
430 |         # Use the provided group_ids or fall back to the default from config if none provided
431 |         effective_group_ids = (
432 |             group_ids
433 |             if group_ids is not None
434 |             else [config.graphiti.group_id]
435 |             if config.graphiti.group_id
436 |             else []
437 |         )
438 | 
439 |         # Create search filters
440 |         search_filters = SearchFilters(
441 |             node_labels=entity_types,
442 |         )
443 | 
444 |         # Use the search_ method with node search config
445 |         from graphiti_core.search.search_config_recipes import NODE_HYBRID_SEARCH_RRF
446 | 
447 |         results = await client.search_(
448 |             query=query,
449 |             config=NODE_HYBRID_SEARCH_RRF,
450 |             group_ids=effective_group_ids,
451 |             search_filter=search_filters,
452 |         )
453 | 
454 |         # Extract nodes from results
455 |         nodes = results.nodes[:max_nodes] if results.nodes else []
456 | 
457 |         if not nodes:
458 |             return NodeSearchResponse(message='No relevant nodes found', nodes=[])
459 | 
460 |         # Format the results
461 |         node_results = []
462 |         for node in nodes:
463 |             # Get attributes and ensure no embeddings are included
464 |             attrs = node.attributes if hasattr(node, 'attributes') else {}
465 |             # Remove any embedding keys that might be in attributes
466 |             attrs = {k: v for k, v in attrs.items() if 'embedding' not in k.lower()}
467 | 
468 |             node_results.append(
469 |                 NodeResult(
470 |                     uuid=node.uuid,
471 |                     name=node.name,
472 |                     labels=node.labels if node.labels else [],
473 |                     created_at=node.created_at.isoformat() if node.created_at else None,
474 |                     summary=node.summary,
475 |                     group_id=node.group_id,
476 |                     attributes=attrs,
477 |                 )
478 |             )
479 | 
480 |         return NodeSearchResponse(message='Nodes retrieved successfully', nodes=node_results)
481 |     except Exception as e:
482 |         error_msg = str(e)
483 |         logger.error(f'Error searching nodes: {error_msg}')
484 |         return ErrorResponse(error=f'Error searching nodes: {error_msg}')
485 | 
486 | 
487 | @mcp.tool()
488 | async def search_memory_facts(
489 |     query: str,
490 |     group_ids: list[str] | None = None,
491 |     max_facts: int = 10,
492 |     center_node_uuid: str | None = None,
493 | ) -> FactSearchResponse | ErrorResponse:
494 |     """Search the graph memory for relevant facts.
495 | 
496 |     Args:
497 |         query: The search query
498 |         group_ids: Optional list of group IDs to filter results
499 |         max_facts: Maximum number of facts to return (default: 10)
500 |         center_node_uuid: Optional UUID of a node to center the search around
501 |     """
502 |     global graphiti_service
503 | 
504 |     if graphiti_service is None:
505 |         return ErrorResponse(error='Graphiti service not initialized')
506 | 
507 |     try:
508 |         # Validate max_facts parameter
509 |         if max_facts <= 0:
510 |             return ErrorResponse(error='max_facts must be a positive integer')
511 | 
512 |         client = await graphiti_service.get_client()
513 | 
514 |         # Use the provided group_ids or fall back to the default from config if none provided
515 |         effective_group_ids = (
516 |             group_ids
517 |             if group_ids is not None
518 |             else [config.graphiti.group_id]
519 |             if config.graphiti.group_id
520 |             else []
521 |         )
522 | 
523 |         relevant_edges = await client.search(
524 |             group_ids=effective_group_ids,
525 |             query=query,
526 |             num_results=max_facts,
527 |             center_node_uuid=center_node_uuid,
528 |         )
529 | 
530 |         if not relevant_edges:
531 |             return FactSearchResponse(message='No relevant facts found', facts=[])
532 | 
533 |         facts = [format_fact_result(edge) for edge in relevant_edges]
534 |         return FactSearchResponse(message='Facts retrieved successfully', facts=facts)
535 |     except Exception as e:
536 |         error_msg = str(e)
537 |         logger.error(f'Error searching facts: {error_msg}')
538 |         return ErrorResponse(error=f'Error searching facts: {error_msg}')
539 | 
540 | 
541 | @mcp.tool()
542 | async def delete_entity_edge(uuid: str) -> SuccessResponse | ErrorResponse:
543 |     """Delete an entity edge from the graph memory.
544 | 
545 |     Args:
546 |         uuid: UUID of the entity edge to delete
547 |     """
548 |     global graphiti_service
549 | 
550 |     if graphiti_service is None:
551 |         return ErrorResponse(error='Graphiti service not initialized')
552 | 
553 |     try:
554 |         client = await graphiti_service.get_client()
555 | 
556 |         # Get the entity edge by UUID
557 |         entity_edge = await EntityEdge.get_by_uuid(client.driver, uuid)
558 |         # Delete the edge using its delete method
559 |         await entity_edge.delete(client.driver)
560 |         return SuccessResponse(message=f'Entity edge with UUID {uuid} deleted successfully')
561 |     except Exception as e:
562 |         error_msg = str(e)
563 |         logger.error(f'Error deleting entity edge: {error_msg}')
564 |         return ErrorResponse(error=f'Error deleting entity edge: {error_msg}')
565 | 
566 | 
567 | @mcp.tool()
568 | async def delete_episode(uuid: str) -> SuccessResponse | ErrorResponse:
569 |     """Delete an episode from the graph memory.
570 | 
571 |     Args:
572 |         uuid: UUID of the episode to delete
573 |     """
574 |     global graphiti_service
575 | 
576 |     if graphiti_service is None:
577 |         return ErrorResponse(error='Graphiti service not initialized')
578 | 
579 |     try:
580 |         client = await graphiti_service.get_client()
581 | 
582 |         # Get the episodic node by UUID
583 |         episodic_node = await EpisodicNode.get_by_uuid(client.driver, uuid)
584 |         # Delete the node using its delete method
585 |         await episodic_node.delete(client.driver)
586 |         return SuccessResponse(message=f'Episode with UUID {uuid} deleted successfully')
587 |     except Exception as e:
588 |         error_msg = str(e)
589 |         logger.error(f'Error deleting episode: {error_msg}')
590 |         return ErrorResponse(error=f'Error deleting episode: {error_msg}')
591 | 
592 | 
593 | @mcp.tool()
594 | async def get_entity_edge(uuid: str) -> dict[str, Any] | ErrorResponse:
595 |     """Get an entity edge from the graph memory by its UUID.
596 | 
597 |     Args:
598 |         uuid: UUID of the entity edge to retrieve
599 |     """
600 |     global graphiti_service
601 | 
602 |     if graphiti_service is None:
603 |         return ErrorResponse(error='Graphiti service not initialized')
604 | 
605 |     try:
606 |         client = await graphiti_service.get_client()
607 | 
608 |         # Get the entity edge directly using the EntityEdge class method
609 |         entity_edge = await EntityEdge.get_by_uuid(client.driver, uuid)
610 | 
611 |         # Use the format_fact_result function to serialize the edge
612 |         # Return the Python dict directly - MCP will handle serialization
613 |         return format_fact_result(entity_edge)
614 |     except Exception as e:
615 |         error_msg = str(e)
616 |         logger.error(f'Error getting entity edge: {error_msg}')
617 |         return ErrorResponse(error=f'Error getting entity edge: {error_msg}')
618 | 
619 | 
620 | @mcp.tool()
621 | async def get_episodes(
622 |     group_ids: list[str] | None = None,
623 |     max_episodes: int = 10,
624 | ) -> EpisodeSearchResponse | ErrorResponse:
625 |     """Get episodes from the graph memory.
626 | 
627 |     Args:
628 |         group_ids: Optional list of group IDs to filter results
629 |         max_episodes: Maximum number of episodes to return (default: 10)
630 |     """
631 |     global graphiti_service
632 | 
633 |     if graphiti_service is None:
634 |         return ErrorResponse(error='Graphiti service not initialized')
635 | 
636 |     try:
637 |         client = await graphiti_service.get_client()
638 | 
639 |         # Use the provided group_ids or fall back to the default from config if none provided
640 |         effective_group_ids = (
641 |             group_ids
642 |             if group_ids is not None
643 |             else [config.graphiti.group_id]
644 |             if config.graphiti.group_id
645 |             else []
646 |         )
647 | 
648 |         # Get episodes from the driver directly
649 |         from graphiti_core.nodes import EpisodicNode
650 | 
651 |         if effective_group_ids:
652 |             episodes = await EpisodicNode.get_by_group_ids(
653 |                 client.driver, effective_group_ids, limit=max_episodes
654 |             )
655 |         else:
656 |             # If no group IDs, we need to use a different approach
657 |             # For now, return empty list when no group IDs specified
658 |             episodes = []
659 | 
660 |         if not episodes:
661 |             return EpisodeSearchResponse(message='No episodes found', episodes=[])
662 | 
663 |         # Format the results
664 |         episode_results = []
665 |         for episode in episodes:
666 |             episode_dict = {
667 |                 'uuid': episode.uuid,
668 |                 'name': episode.name,
669 |                 'content': episode.content,
670 |                 'created_at': episode.created_at.isoformat() if episode.created_at else None,
671 |                 'source': episode.source.value
672 |                 if hasattr(episode.source, 'value')
673 |                 else str(episode.source),
674 |                 'source_description': episode.source_description,
675 |                 'group_id': episode.group_id,
676 |             }
677 |             episode_results.append(episode_dict)
678 | 
679 |         return EpisodeSearchResponse(
680 |             message='Episodes retrieved successfully', episodes=episode_results
681 |         )
682 |     except Exception as e:
683 |         error_msg = str(e)
684 |         logger.error(f'Error getting episodes: {error_msg}')
685 |         return ErrorResponse(error=f'Error getting episodes: {error_msg}')
686 | 
687 | 
688 | @mcp.tool()
689 | async def clear_graph(group_ids: list[str] | None = None) -> SuccessResponse | ErrorResponse:
690 |     """Clear all data from the graph for specified group IDs.
691 | 
692 |     Args:
693 |         group_ids: Optional list of group IDs to clear. If not provided, clears the default group.
694 |     """
695 |     global graphiti_service
696 | 
697 |     if graphiti_service is None:
698 |         return ErrorResponse(error='Graphiti service not initialized')
699 | 
700 |     try:
701 |         client = await graphiti_service.get_client()
702 | 
703 |         # Use the provided group_ids or fall back to the default from config if none provided
704 |         effective_group_ids = (
705 |             group_ids or [config.graphiti.group_id] if config.graphiti.group_id else []
706 |         )
707 | 
708 |         if not effective_group_ids:
709 |             return ErrorResponse(error='No group IDs specified for clearing')
710 | 
711 |         # Clear data for the specified group IDs
712 |         await clear_data(client.driver, group_ids=effective_group_ids)
713 | 
714 |         return SuccessResponse(
715 |             message=f'Graph data cleared successfully for group IDs: {", ".join(effective_group_ids)}'
716 |         )
717 |     except Exception as e:
718 |         error_msg = str(e)
719 |         logger.error(f'Error clearing graph: {error_msg}')
720 |         return ErrorResponse(error=f'Error clearing graph: {error_msg}')
721 | 
722 | 
723 | @mcp.tool()
724 | async def get_status() -> StatusResponse:
725 |     """Get the status of the Graphiti MCP server and database connection."""
726 |     global graphiti_service
727 | 
728 |     if graphiti_service is None:
729 |         return StatusResponse(status='error', message='Graphiti service not initialized')
730 | 
731 |     try:
732 |         client = await graphiti_service.get_client()
733 | 
734 |         # Test database connection with a simple query
735 |         async with client.driver.session() as session:
736 |             result = await session.run('MATCH (n) RETURN count(n) as count')
737 |             # Consume the result to verify query execution
738 |             if result:
739 |                 _ = [record async for record in result]
740 | 
741 |         # Use the provider from the service's config, not the global
742 |         provider_name = graphiti_service.config.database.provider
743 |         return StatusResponse(
744 |             status='ok',
745 |             message=f'Graphiti MCP server is running and connected to {provider_name} database',
746 |         )
747 |     except Exception as e:
748 |         error_msg = str(e)
749 |         logger.error(f'Error checking database connection: {error_msg}')
750 |         return StatusResponse(
751 |             status='error',
752 |             message=f'Graphiti MCP server is running but database connection failed: {error_msg}',
753 |         )
754 | 
755 | 
756 | @mcp.custom_route('/health', methods=['GET'])
757 | async def health_check(request) -> JSONResponse:
758 |     """Health check endpoint for Docker and load balancers."""
759 |     return JSONResponse({'status': 'healthy', 'service': 'graphiti-mcp'})
760 | 
761 | 
762 | async def initialize_server() -> ServerConfig:
763 |     """Parse CLI arguments and initialize the Graphiti server configuration."""
764 |     global config, graphiti_service, queue_service, graphiti_client, semaphore
765 | 
766 |     parser = argparse.ArgumentParser(
767 |         description='Run the Graphiti MCP server with YAML configuration support'
768 |     )
769 | 
770 |     # Configuration file argument
771 |     # Default to config/config.yaml relative to the mcp_server directory
772 |     default_config = Path(__file__).parent.parent / 'config' / 'config.yaml'
773 |     parser.add_argument(
774 |         '--config',
775 |         type=Path,
776 |         default=default_config,
777 |         help='Path to YAML configuration file (default: config/config.yaml)',
778 |     )
779 | 
780 |     # Transport arguments
781 |     parser.add_argument(
782 |         '--transport',
783 |         choices=['sse', 'stdio', 'http'],
784 |         help='Transport to use: http (recommended, default), stdio (standard I/O), or sse (deprecated)',
785 |     )
786 |     parser.add_argument(
787 |         '--host',
788 |         help='Host to bind the MCP server to',
789 |     )
790 |     parser.add_argument(
791 |         '--port',
792 |         type=int,
793 |         help='Port to bind the MCP server to',
794 |     )
795 | 
796 |     # Provider selection arguments
797 |     parser.add_argument(
798 |         '--llm-provider',
799 |         choices=['openai', 'azure_openai', 'anthropic', 'gemini', 'groq'],
800 |         help='LLM provider to use',
801 |     )
802 |     parser.add_argument(
803 |         '--embedder-provider',
804 |         choices=['openai', 'azure_openai', 'gemini', 'voyage'],
805 |         help='Embedder provider to use',
806 |     )
807 |     parser.add_argument(
808 |         '--database-provider',
809 |         choices=['neo4j', 'falkordb'],
810 |         help='Database provider to use',
811 |     )
812 | 
813 |     # LLM configuration arguments
814 |     parser.add_argument('--model', help='Model name to use with the LLM client')
815 |     parser.add_argument('--small-model', help='Small model name to use with the LLM client')
816 |     parser.add_argument(
817 |         '--temperature', type=float, help='Temperature setting for the LLM (0.0-2.0)'
818 |     )
819 | 
820 |     # Embedder configuration arguments
821 |     parser.add_argument('--embedder-model', help='Model name to use with the embedder')
822 | 
823 |     # Graphiti-specific arguments
824 |     parser.add_argument(
825 |         '--group-id',
826 |         help='Namespace for the graph. If not provided, uses config file or generates random UUID.',
827 |     )
828 |     parser.add_argument(
829 |         '--user-id',
830 |         help='User ID for tracking operations',
831 |     )
832 |     parser.add_argument(
833 |         '--destroy-graph',
834 |         action='store_true',
835 |         help='Destroy all Graphiti graphs on startup',
836 |     )
837 | 
838 |     args = parser.parse_args()
839 | 
840 |     # Set config path in environment for the settings to pick up
841 |     if args.config:
842 |         os.environ['CONFIG_PATH'] = str(args.config)
843 | 
844 |     # Load configuration with environment variables and YAML
845 |     config = GraphitiConfig()
846 | 
847 |     # Apply CLI overrides
848 |     config.apply_cli_overrides(args)
849 | 
850 |     # Also apply legacy CLI args for backward compatibility
851 |     if hasattr(args, 'destroy_graph'):
852 |         config.destroy_graph = args.destroy_graph
853 | 
854 |     # Log configuration details
855 |     logger.info('Using configuration:')
856 |     logger.info(f'  - LLM: {config.llm.provider} / {config.llm.model}')
857 |     logger.info(f'  - Embedder: {config.embedder.provider} / {config.embedder.model}')
858 |     logger.info(f'  - Database: {config.database.provider}')
859 |     logger.info(f'  - Group ID: {config.graphiti.group_id}')
860 |     logger.info(f'  - Transport: {config.server.transport}')
861 | 
862 |     # Log graphiti-core version
863 |     try:
864 |         import graphiti_core
865 | 
866 |         graphiti_version = getattr(graphiti_core, '__version__', 'unknown')
867 |         logger.info(f'  - Graphiti Core: {graphiti_version}')
868 |     except Exception:
869 |         # Check for Docker-stored version file
870 |         version_file = Path('/app/.graphiti-core-version')
871 |         if version_file.exists():
872 |             graphiti_version = version_file.read_text().strip()
873 |             logger.info(f'  - Graphiti Core: {graphiti_version}')
874 |         else:
875 |             logger.info('  - Graphiti Core: version unavailable')
876 | 
877 |     # Handle graph destruction if requested
878 |     if hasattr(config, 'destroy_graph') and config.destroy_graph:
879 |         logger.warning('Destroying all Graphiti graphs as requested...')
880 |         temp_service = GraphitiService(config, SEMAPHORE_LIMIT)
881 |         await temp_service.initialize()
882 |         client = await temp_service.get_client()
883 |         await clear_data(client.driver)
884 |         logger.info('All graphs destroyed')
885 | 
886 |     # Initialize services
887 |     graphiti_service = GraphitiService(config, SEMAPHORE_LIMIT)
888 |     queue_service = QueueService()
889 |     await graphiti_service.initialize()
890 | 
891 |     # Set global client for backward compatibility
892 |     graphiti_client = await graphiti_service.get_client()
893 |     semaphore = graphiti_service.semaphore
894 | 
895 |     # Initialize queue service with the client
896 |     await queue_service.initialize(graphiti_client)
897 | 
898 |     # Set MCP server settings
899 |     if config.server.host:
900 |         mcp.settings.host = config.server.host
901 |     if config.server.port:
902 |         mcp.settings.port = config.server.port
903 | 
904 |     # Return MCP configuration for transport
905 |     return config.server
906 | 
907 | 
908 | async def run_mcp_server():
909 |     """Run the MCP server in the current event loop."""
910 |     # Initialize the server
911 |     mcp_config = await initialize_server()
912 | 
913 |     # Run the server with configured transport
914 |     logger.info(f'Starting MCP server with transport: {mcp_config.transport}')
915 |     if mcp_config.transport == 'stdio':
916 |         await mcp.run_stdio_async()
917 |     elif mcp_config.transport == 'sse':
918 |         logger.info(
919 |             f'Running MCP server with SSE transport on {mcp.settings.host}:{mcp.settings.port}'
920 |         )
921 |         logger.info(f'Access the server at: http://{mcp.settings.host}:{mcp.settings.port}/sse')
922 |         await mcp.run_sse_async()
923 |     elif mcp_config.transport == 'http':
924 |         # Use localhost for display if binding to 0.0.0.0
925 |         display_host = 'localhost' if mcp.settings.host == '0.0.0.0' else mcp.settings.host
926 |         logger.info(
927 |             f'Running MCP server with streamable HTTP transport on {mcp.settings.host}:{mcp.settings.port}'
928 |         )
929 |         logger.info('=' * 60)
930 |         logger.info('MCP Server Access Information:')
931 |         logger.info(f'  Base URL: http://{display_host}:{mcp.settings.port}/')
932 |         logger.info(f'  MCP Endpoint: http://{display_host}:{mcp.settings.port}/mcp/')
933 |         logger.info('  Transport: HTTP (streamable)')
934 | 
935 |         # Show FalkorDB Browser UI access if enabled
936 |         if os.environ.get('BROWSER', '1') == '1':
937 |             logger.info(f'  FalkorDB Browser UI: http://{display_host}:3000/')
938 | 
939 |         logger.info('=' * 60)
940 |         logger.info('For MCP clients, connect to the /mcp/ endpoint above')
941 | 
942 |         # Configure uvicorn logging to match our format
943 |         configure_uvicorn_logging()
944 | 
945 |         await mcp.run_streamable_http_async()
946 |     else:
947 |         raise ValueError(
948 |             f'Unsupported transport: {mcp_config.transport}. Use "sse", "stdio", or "http"'
949 |         )
950 | 
951 | 
952 | def main():
953 |     """Main function to run the Graphiti MCP server."""
954 |     try:
955 |         # Run everything in a single event loop
956 |         asyncio.run(run_mcp_server())
957 |     except KeyboardInterrupt:
958 |         logger.info('Server shutting down...')
959 |     except Exception as e:
960 |         logger.error(f'Error initializing Graphiti MCP server: {str(e)}')
961 |         raise
962 | 
963 | 
964 | if __name__ == '__main__':
965 |     main()
966 | 
```

--------------------------------------------------------------------------------
/examples/data/manybirds_products.json:
--------------------------------------------------------------------------------

```json
   1 | {
   2 |   "products": [
   3 |     {
   4 |       "id": 6785367965776,
   5 |       "title": "TinyBirds Wool Runners - Little Kids - Natural Black (Blizzard Sole)",
   6 |       "handle": "TinyBirds-wool-runners-little-kids",
   7 |       "body_html": "TinyBirds are eco-friendly and machine washable sneakers for kids. Super soft and cozy and made with comfortable, itch-free ZQ Merino Wool, they're the perfect pair for kids of all ages.",
   8 |       "published_at": "2024-08-21T10:07:25-07:00",
   9 |       "created_at": "2023-01-03T16:00:31-08:00",
  10 |       "updated_at": "2024-08-24T17:56:38-07:00",
  11 |       "vendor": "Manybirds",
  12 |       "product_type": "Shoes",
  13 |       "tags": [
  14 |         "Manybirds::carbon-score = 3.06",
  15 |         "Manybirds::cfId = color-TinyBirds-wool-runners-natural-black-blizzard-ne",
  16 |         "Manybirds::complete = true",
  17 |         "Manybirds::edition = classic",
  18 |         "Manybirds::gender = toddler",
  19 |         "Manybirds::hue = black",
  20 |         "Manybirds::master = TinyBirds-wool-runners-little-kids",
  21 |         "Manybirds::material = wool",
  22 |         "Manybirds::price-tier = tier-1",
  23 |         "Manybirds::silhouette = runner",
  24 |         "loop::returnable = true",
  25 |         "shoprunner",
  26 |         "YCRF_unisex-smallbird-shoes",
  27 |         "YGroup_ygroup_TinyBirds-wool-runners-little-kids"
  28 |       ],
  29 |       "variants": [
  30 |         {
  31 |           "id": 40015831531600,
  32 |           "title": "5T",
  33 |           "option1": "5T",
  34 |           "option2": null,
  35 |           "option3": null,
  36 |           "sku": "AB00DFT050",
  37 |           "requires_shipping": true,
  38 |           "taxable": true,
  39 |           "featured_image": null,
  40 |           "available": false,
  41 |           "price": "25.00",
  42 |           "grams": 290,
  43 |           "compare_at_price": "60.00",
  44 |           "position": 1,
  45 |           "product_id": 6785367965776,
  46 |           "created_at": "2023-01-03T16:00:32-08:00",
  47 |           "updated_at": "2024-08-24T17:56:38-07:00"
  48 |         },
  49 |         {
  50 |           "id": 40015831564368,
  51 |           "title": "6T",
  52 |           "option1": "6T",
  53 |           "option2": null,
  54 |           "option3": null,
  55 |           "sku": "AB00DFT060",
  56 |           "requires_shipping": true,
  57 |           "taxable": true,
  58 |           "featured_image": null,
  59 |           "available": false,
  60 |           "price": "25.00",
  61 |           "grams": 310,
  62 |           "compare_at_price": "60.00",
  63 |           "position": 2,
  64 |           "product_id": 6785367965776,
  65 |           "created_at": "2023-01-03T16:00:32-08:00",
  66 |           "updated_at": "2024-08-24T17:56:38-07:00"
  67 |         },
  68 |         {
  69 |           "id": 40015831597136,
  70 |           "title": "7T",
  71 |           "option1": "7T",
  72 |           "option2": null,
  73 |           "option3": null,
  74 |           "sku": "AB00DFT070",
  75 |           "requires_shipping": true,
  76 |           "taxable": true,
  77 |           "featured_image": null,
  78 |           "available": false,
  79 |           "price": "25.00",
  80 |           "grams": 320,
  81 |           "compare_at_price": "60.00",
  82 |           "position": 3,
  83 |           "product_id": 6785367965776,
  84 |           "created_at": "2023-01-03T16:00:32-08:00",
  85 |           "updated_at": "2024-08-24T17:56:38-07:00"
  86 |         },
  87 |         {
  88 |           "id": 40015831629904,
  89 |           "title": "8T",
  90 |           "option1": "8T",
  91 |           "option2": null,
  92 |           "option3": null,
  93 |           "sku": "AB00DFT080",
  94 |           "requires_shipping": true,
  95 |           "taxable": true,
  96 |           "featured_image": null,
  97 |           "available": false,
  98 |           "price": "25.00",
  99 |           "grams": 340,
 100 |           "compare_at_price": "60.00",
 101 |           "position": 4,
 102 |           "product_id": 6785367965776,
 103 |           "created_at": "2023-01-03T16:00:32-08:00",
 104 |           "updated_at": "2024-08-24T17:56:38-07:00"
 105 |         },
 106 |         {
 107 |           "id": 40015831662672,
 108 |           "title": "9T",
 109 |           "option1": "9T",
 110 |           "option2": null,
 111 |           "option3": null,
 112 |           "sku": "AB00DFT090",
 113 |           "requires_shipping": true,
 114 |           "taxable": true,
 115 |           "featured_image": null,
 116 |           "available": false,
 117 |           "price": "25.00",
 118 |           "grams": 350,
 119 |           "compare_at_price": "60.00",
 120 |           "position": 5,
 121 |           "product_id": 6785367965776,
 122 |           "created_at": "2023-01-03T16:00:32-08:00",
 123 |           "updated_at": "2024-08-24T17:56:38-07:00"
 124 |         },
 125 |         {
 126 |           "id": 40015831695440,
 127 |           "title": "10T",
 128 |           "option1": "10T",
 129 |           "option2": null,
 130 |           "option3": null,
 131 |           "sku": "AB00DFT100",
 132 |           "requires_shipping": true,
 133 |           "taxable": true,
 134 |           "featured_image": null,
 135 |           "available": false,
 136 |           "price": "25.00",
 137 |           "grams": 360,
 138 |           "compare_at_price": "60.00",
 139 |           "position": 6,
 140 |           "product_id": 6785367965776,
 141 |           "created_at": "2023-01-03T16:00:32-08:00",
 142 |           "updated_at": "2024-08-24T17:56:38-07:00"
 143 |         }
 144 |       ],
 145 |       "images": [
 146 |         {
 147 |           "id": 30703127068752,
 148 |           "created_at": "2023-01-03T16:00:32-08:00",
 149 |           "position": 1,
 150 |           "updated_at": "2023-01-03T16:00:32-08:00",
 151 |           "product_id": 6785367965776,
 152 |           "variant_ids": [],
 153 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/products\/AB008ET_Shoe_Angle_Global_Little_Kids_Wool_Runner_Natural_Black_Blizzard_d532e5f4-50f5-49af-964a-52906e1fd3d1.png?v=1672790432",
 154 |           "width": 1600,
 155 |           "height": 1600
 156 |         },
 157 |         {
 158 |           "id": 30703127101520,
 159 |           "created_at": "2023-01-03T16:00:32-08:00",
 160 |           "position": 2,
 161 |           "updated_at": "2023-01-03T16:00:32-08:00",
 162 |           "product_id": 6785367965776,
 163 |           "variant_ids": [],
 164 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/products\/WR-PDP-Little_Kids_e389b4fb-5f67-4232-919b-5f18e95eb301.jpg?v=1672790432",
 165 |           "width": 1600,
 166 |           "height": 1600
 167 |         },
 168 |         {
 169 |           "id": 30703127134288,
 170 |           "created_at": "2023-01-03T16:00:32-08:00",
 171 |           "position": 3,
 172 |           "updated_at": "2023-01-03T16:00:32-08:00",
 173 |           "product_id": 6785367965776,
 174 |           "variant_ids": [],
 175 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/products\/AB008ET_Shoe_Left_Global_Little_Kids_Wool_Runner_Natural_Black_Blizzard_76c2d640-e476-4fa5-985d-ddb48a20b6fb.png?v=1672790432",
 176 |           "width": 1110,
 177 |           "height": 1110
 178 |         },
 179 |         {
 180 |           "id": 30703127167056,
 181 |           "created_at": "2023-01-03T16:00:32-08:00",
 182 |           "position": 4,
 183 |           "updated_at": "2023-01-03T16:00:32-08:00",
 184 |           "product_id": 6785367965776,
 185 |           "variant_ids": [],
 186 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/products\/AB008ET_Shoe_Back_Global_Little_Kids_Wool_Runner_Natural_Black_Blizzard_744e7e0f-10e7-4712-83d9-3a907f7ed1d9.png?v=1672790432",
 187 |           "width": 1600,
 188 |           "height": 1600
 189 |         },
 190 |         {
 191 |           "id": 30703127199824,
 192 |           "created_at": "2023-01-03T16:00:32-08:00",
 193 |           "position": 5,
 194 |           "updated_at": "2023-01-03T16:00:32-08:00",
 195 |           "product_id": 6785367965776,
 196 |           "variant_ids": [],
 197 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/products\/AB008ET_Shoe_Top_Global_Little_Kids_Wool_Runner_Natural_Black_Blizzard_9075685f-39f3-454b-a19f-1c15f1c0ee5c.png?v=1672790432",
 198 |           "width": 1600,
 199 |           "height": 1600
 200 |         },
 201 |         {
 202 |           "id": 30703127232592,
 203 |           "created_at": "2023-01-03T16:00:32-08:00",
 204 |           "position": 6,
 205 |           "updated_at": "2023-01-03T16:00:32-08:00",
 206 |           "product_id": 6785367965776,
 207 |           "variant_ids": [],
 208 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/products\/AB008ET_Shoe_Bottom_Global_Little_Kids_Wool_Runner_Natural_Black_Blizzard_ebe5612a-44e3-4e53-864c-a02899ad2ce6.png?v=1672790432",
 209 |           "width": 1600,
 210 |           "height": 1600
 211 |         }
 212 |       ],
 213 |       "options": [
 214 |         {
 215 |           "name": "Size",
 216 |           "position": 1,
 217 |           "values": [
 218 |             "5T",
 219 |             "6T",
 220 |             "7T",
 221 |             "8T",
 222 |             "9T",
 223 |             "10T"
 224 |           ]
 225 |         }
 226 |       ]
 227 |     },
 228 |     {
 229 |       "id": 6889961750608,
 230 |       "title": "Anytime No Show Sock - Rugged Beige",
 231 |       "handle": "anytime-no-show-sock-rugged-beige",
 232 |       "body_html": "Soft, breathable, and super durable, these lightweight socks are designed to stay put so no one will even know they\u2019re there\u2014unless you blow their cover.",
 233 |       "published_at": "2024-08-21T08:50:07-07:00",
 234 |       "created_at": "2023-10-30T20:22:43-07:00",
 235 |       "updated_at": "2024-08-24T17:56:38-07:00",
 236 |       "vendor": "Manybirds",
 237 |       "product_type": "Socks",
 238 |       "tags": [
 239 |         "Manybirds::carbon-score = 0.71",
 240 |         "Manybirds::cfId = color-anytime-no-show-sock-rugged-beige",
 241 |         "Manybirds::complete = true",
 242 |         "Manybirds::edition = limited",
 243 |         "Manybirds::gender = unisex",
 244 |         "Manybirds::hue = beige",
 245 |         "Manybirds::master = anytime-no-show-sock",
 246 |         "Manybirds::material = cotton",
 247 |         "Manybirds::price-tier = msrp",
 248 |         "Manybirds::silhouette = hider",
 249 |         "loop::returnable = true",
 250 |         "shoprunner",
 251 |         "YCRF_socks",
 252 |         "YGroup_ygroup_anytime-no-show-sock"
 253 |       ],
 254 |       "variants": [
 255 |         {
 256 |           "id": 40356479500368,
 257 |           "title": "S (W5-7)",
 258 |           "option1": "S (W5-7)",
 259 |           "option2": null,
 260 |           "option3": null,
 261 |           "sku": "A10849U001",
 262 |           "requires_shipping": true,
 263 |           "taxable": true,
 264 |           "featured_image": null,
 265 |           "available": true,
 266 |           "price": "14.00",
 267 |           "grams": 59,
 268 |           "compare_at_price": null,
 269 |           "position": 1,
 270 |           "product_id": 6889961750608,
 271 |           "created_at": "2023-10-30T20:22:43-07:00",
 272 |           "updated_at": "2024-08-24T17:56:38-07:00"
 273 |         },
 274 |         {
 275 |           "id": 40356479533136,
 276 |           "title": "M (W8-10 \/ M8)",
 277 |           "option1": "M (W8-10 \/ M8)",
 278 |           "option2": null,
 279 |           "option3": null,
 280 |           "sku": "A10849U002",
 281 |           "requires_shipping": true,
 282 |           "taxable": true,
 283 |           "featured_image": null,
 284 |           "available": true,
 285 |           "price": "14.00",
 286 |           "grams": 56,
 287 |           "compare_at_price": null,
 288 |           "position": 2,
 289 |           "product_id": 6889961750608,
 290 |           "created_at": "2023-10-30T20:22:43-07:00",
 291 |           "updated_at": "2024-08-24T17:56:38-07:00"
 292 |         },
 293 |         {
 294 |           "id": 40356479565904,
 295 |           "title": "L (W11 M9-12)",
 296 |           "option1": "L (W11 M9-12)",
 297 |           "option2": null,
 298 |           "option3": null,
 299 |           "sku": "A10849U003",
 300 |           "requires_shipping": true,
 301 |           "taxable": true,
 302 |           "featured_image": null,
 303 |           "available": true,
 304 |           "price": "14.00",
 305 |           "grams": 52,
 306 |           "compare_at_price": null,
 307 |           "position": 3,
 308 |           "product_id": 6889961750608,
 309 |           "created_at": "2023-10-30T20:22:43-07:00",
 310 |           "updated_at": "2024-08-24T17:56:38-07:00"
 311 |         },
 312 |         {
 313 |           "id": 40356479598672,
 314 |           "title": "XL (M13-14)",
 315 |           "option1": "XL (M13-14)",
 316 |           "option2": null,
 317 |           "option3": null,
 318 |           "sku": "A10849U004",
 319 |           "requires_shipping": true,
 320 |           "taxable": true,
 321 |           "featured_image": null,
 322 |           "available": true,
 323 |           "price": "14.00",
 324 |           "grams": 50,
 325 |           "compare_at_price": null,
 326 |           "position": 4,
 327 |           "product_id": 6889961750608,
 328 |           "created_at": "2023-10-30T20:22:43-07:00",
 329 |           "updated_at": "2024-08-24T17:56:38-07:00"
 330 |         }
 331 |       ],
 332 |       "images": [
 333 |         {
 334 |           "id": 31822180155472,
 335 |           "created_at": "2024-04-05T14:20:41-07:00",
 336 |           "position": 1,
 337 |           "updated_at": "2024-04-05T14:20:41-07:00",
 338 |           "product_id": 6889961750608,
 339 |           "variant_ids": [],
 340 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10849_S24Q1_Anytime_No_Show_Sock_Rugged_Beige_A-1400x1400.png?v=1712352041",
 341 |           "width": 1400,
 342 |           "height": 1400
 343 |         },
 344 |         {
 345 |           "id": 31822180188240,
 346 |           "created_at": "2024-04-05T14:20:41-07:00",
 347 |           "position": 2,
 348 |           "updated_at": "2024-04-05T14:20:41-07:00",
 349 |           "product_id": 6889961750608,
 350 |           "variant_ids": [],
 351 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10849_S24Q1_Anytime_No_Show_Sock_Rugged_Beige_B-1400x1400.png?v=1712352041",
 352 |           "width": 1400,
 353 |           "height": 1400
 354 |         }
 355 |       ],
 356 |       "options": [
 357 |         {
 358 |           "name": "Size",
 359 |           "position": 1,
 360 |           "values": [
 361 |             "S (W5-7)",
 362 |             "M (W8-10 \/ M8)",
 363 |             "L (W11 M9-12)",
 364 |             "XL (M13-14)"
 365 |           ]
 366 |         }
 367 |       ]
 368 |     },
 369 |     {
 370 |       "id": 6919095189584,
 371 |       "title": "Men's Couriers - Natural Black\/Basin Blue (Blizzard Sole)",
 372 |       "handle": "mens-couriers-natural-black-basin-blue",
 373 |       "body_html": "Our nod to a vintage sneaker made with natural materials for a better future. The retro silhouette elevated with intricate details pairs with anything you have planned. Come for the throwback style, and stay for the cushy all-day-wearability.",
 374 |       "published_at": "2024-08-19T17:08:34-07:00",
 375 |       "created_at": "2024-01-10T21:53:11-08:00",
 376 |       "updated_at": "2024-08-24T17:56:38-07:00",
 377 |       "vendor": "Manybirds",
 378 |       "product_type": "Shoes",
 379 |       "tags": [
 380 |         "Manybirds::carbon-score = 5.51",
 381 |         "Manybirds::cfId = color-mens-couriers-ntl-blk-multi-blzz",
 382 |         "Manybirds::complete = true",
 383 |         "Manybirds::edition = limited",
 384 |         "Manybirds::gender = mens",
 385 |         "Manybirds::hue = black",
 386 |         "Manybirds::hue = blue",
 387 |         "Manybirds::master = mens-couriers",
 388 |         "Manybirds::material = cotton",
 389 |         "Manybirds::price-tier = msrp",
 390 |         "Manybirds::silhouette = runner",
 391 |         "loop::returnable = true",
 392 |         "shoprunner",
 393 |         "YCRF_mens-move-shoes",
 394 |         "YGroup_ygroup_mens-couriers"
 395 |       ],
 396 |       "variants": [
 397 |         {
 398 |           "id": 40444543696976,
 399 |           "title": "8",
 400 |           "option1": "8",
 401 |           "option2": null,
 402 |           "option3": null,
 403 |           "sku": "A10875M080",
 404 |           "requires_shipping": true,
 405 |           "taxable": true,
 406 |           "featured_image": null,
 407 |           "available": true,
 408 |           "price": "98.00",
 409 |           "grams": 860,
 410 |           "compare_at_price": null,
 411 |           "position": 1,
 412 |           "product_id": 6919095189584,
 413 |           "created_at": "2024-01-10T21:53:12-08:00",
 414 |           "updated_at": "2024-08-24T17:56:38-07:00"
 415 |         },
 416 |         {
 417 |           "id": 40444543729744,
 418 |           "title": "9",
 419 |           "option1": "9",
 420 |           "option2": null,
 421 |           "option3": null,
 422 |           "sku": "A10875M090",
 423 |           "requires_shipping": true,
 424 |           "taxable": true,
 425 |           "featured_image": null,
 426 |           "available": true,
 427 |           "price": "98.00",
 428 |           "grams": 923,
 429 |           "compare_at_price": null,
 430 |           "position": 2,
 431 |           "product_id": 6919095189584,
 432 |           "created_at": "2024-01-10T21:53:12-08:00",
 433 |           "updated_at": "2024-08-24T17:56:38-07:00"
 434 |         },
 435 |         {
 436 |           "id": 40444543762512,
 437 |           "title": "10",
 438 |           "option1": "10",
 439 |           "option2": null,
 440 |           "option3": null,
 441 |           "sku": "A10875M100",
 442 |           "requires_shipping": true,
 443 |           "taxable": true,
 444 |           "featured_image": null,
 445 |           "available": true,
 446 |           "price": "98.00",
 447 |           "grams": 965,
 448 |           "compare_at_price": null,
 449 |           "position": 3,
 450 |           "product_id": 6919095189584,
 451 |           "created_at": "2024-01-10T21:53:12-08:00",
 452 |           "updated_at": "2024-08-24T17:56:38-07:00"
 453 |         },
 454 |         {
 455 |           "id": 40444543795280,
 456 |           "title": "11",
 457 |           "option1": "11",
 458 |           "option2": null,
 459 |           "option3": null,
 460 |           "sku": "A10875M110",
 461 |           "requires_shipping": true,
 462 |           "taxable": true,
 463 |           "featured_image": null,
 464 |           "available": true,
 465 |           "price": "98.00",
 466 |           "grams": 1027,
 467 |           "compare_at_price": null,
 468 |           "position": 4,
 469 |           "product_id": 6919095189584,
 470 |           "created_at": "2024-01-10T21:53:12-08:00",
 471 |           "updated_at": "2024-08-24T17:56:38-07:00"
 472 |         },
 473 |         {
 474 |           "id": 40444543828048,
 475 |           "title": "12",
 476 |           "option1": "12",
 477 |           "option2": null,
 478 |           "option3": null,
 479 |           "sku": "A10875M120",
 480 |           "requires_shipping": true,
 481 |           "taxable": true,
 482 |           "featured_image": null,
 483 |           "available": true,
 484 |           "price": "98.00",
 485 |           "grams": 1076,
 486 |           "compare_at_price": null,
 487 |           "position": 5,
 488 |           "product_id": 6919095189584,
 489 |           "created_at": "2024-01-10T21:53:12-08:00",
 490 |           "updated_at": "2024-08-24T17:56:38-07:00"
 491 |         },
 492 |         {
 493 |           "id": 40444543860816,
 494 |           "title": "13",
 495 |           "option1": "13",
 496 |           "option2": null,
 497 |           "option3": null,
 498 |           "sku": "A10875M130",
 499 |           "requires_shipping": true,
 500 |           "taxable": true,
 501 |           "featured_image": null,
 502 |           "available": true,
 503 |           "price": "98.00",
 504 |           "grams": 1137,
 505 |           "compare_at_price": null,
 506 |           "position": 6,
 507 |           "product_id": 6919095189584,
 508 |           "created_at": "2024-01-10T21:53:12-08:00",
 509 |           "updated_at": "2024-08-24T17:56:38-07:00"
 510 |         },
 511 |         {
 512 |           "id": 40444543893584,
 513 |           "title": "14",
 514 |           "option1": "14",
 515 |           "option2": null,
 516 |           "option3": null,
 517 |           "sku": "A10875M140",
 518 |           "requires_shipping": true,
 519 |           "taxable": true,
 520 |           "featured_image": null,
 521 |           "available": true,
 522 |           "price": "98.00",
 523 |           "grams": 1185,
 524 |           "compare_at_price": null,
 525 |           "position": 7,
 526 |           "product_id": 6919095189584,
 527 |           "created_at": "2024-01-10T21:53:12-08:00",
 528 |           "updated_at": "2024-08-24T17:56:38-07:00"
 529 |         }
 530 |       ],
 531 |       "images": [
 532 |         {
 533 |           "id": 32177950490704,
 534 |           "created_at": "2024-07-05T15:28:37-07:00",
 535 |           "position": 1,
 536 |           "updated_at": "2024-07-05T15:28:37-07:00",
 537 |           "product_id": 6919095189584,
 538 |           "variant_ids": [],
 539 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10875_24Q3_Courier_Natural_Black_Multi_Blizzard_PDP_SINGLE_3Q_3f10aae5-fb6e-4424-b6a9-a8e4134a9318.png?v=1720218517",
 540 |           "width": 4000,
 541 |           "height": 4000
 542 |         },
 543 |         {
 544 |           "id": 32177950523472,
 545 |           "created_at": "2024-07-05T15:28:37-07:00",
 546 |           "position": 2,
 547 |           "updated_at": "2024-07-05T15:28:37-07:00",
 548 |           "product_id": 6919095189584,
 549 |           "variant_ids": [],
 550 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10875_24Q3_Courier_Natural_Black_Multi_Blizzard_PDP_LEFT_b55bab7e-0e85-40be-b457-761165491d76.png?v=1720218517",
 551 |           "width": 1110,
 552 |           "height": 1110
 553 |         },
 554 |         {
 555 |           "id": 32177950556240,
 556 |           "created_at": "2024-07-05T15:28:37-07:00",
 557 |           "position": 3,
 558 |           "updated_at": "2024-07-05T15:28:37-07:00",
 559 |           "product_id": 6919095189584,
 560 |           "variant_ids": [],
 561 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10875_24Q3_Courier_Natural_Black_Multi_Blizzard_PDP_BACK_e6bb4a6b-5d6a-41f3-93ba-6e7a2a142796.png?v=1720218517",
 562 |           "width": 4000,
 563 |           "height": 4000
 564 |         },
 565 |         {
 566 |           "id": 32177950589008,
 567 |           "created_at": "2024-07-05T15:28:37-07:00",
 568 |           "position": 4,
 569 |           "updated_at": "2024-07-05T15:28:37-07:00",
 570 |           "product_id": 6919095189584,
 571 |           "variant_ids": [],
 572 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10875_24Q3_Courier_Natural_Black_Multi_Blizzard_PDP_TD_8a2d64ab-f013-4683-85cd-7ce1daa19eae.png?v=1720218517",
 573 |           "width": 4000,
 574 |           "height": 4000
 575 |         },
 576 |         {
 577 |           "id": 32177950621776,
 578 |           "created_at": "2024-07-05T15:28:37-07:00",
 579 |           "position": 5,
 580 |           "updated_at": "2024-07-05T15:28:37-07:00",
 581 |           "product_id": 6919095189584,
 582 |           "variant_ids": [],
 583 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10875_24Q3_Courier_Natural_Black_Multi_Blizzard_PDP_SOLE_44264878-bed1-4f02-b80b-1f15a7b941be.png?v=1720218517",
 584 |           "width": 4000,
 585 |           "height": 4000
 586 |         },
 587 |         {
 588 |           "id": 32177950654544,
 589 |           "created_at": "2024-07-05T15:28:37-07:00",
 590 |           "position": 6,
 591 |           "updated_at": "2024-07-05T15:28:37-07:00",
 592 |           "product_id": 6919095189584,
 593 |           "variant_ids": [],
 594 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10875_24Q3_Courier_Natural_Black_Multi_Blizzard_PDP_PAIR_3Q_52f5f245-d1e6-4bb3-925c-863d70f1ead8.png?v=1720218517",
 595 |           "width": 4000,
 596 |           "height": 4000
 597 |         }
 598 |       ],
 599 |       "options": [
 600 |         {
 601 |           "name": "Size",
 602 |           "position": 1,
 603 |           "values": [
 604 |             "8",
 605 |             "9",
 606 |             "10",
 607 |             "11",
 608 |             "12",
 609 |             "13",
 610 |             "14"
 611 |           ]
 612 |         }
 613 |       ]
 614 |     },
 615 |     {
 616 |       "id": 6864490004560,
 617 |       "title": "Men's SuperLight Wool Runners - Dark Grey (Medium Grey Sole)",
 618 |       "handle": "mens-superlight-wool-runners-dark-grey",
 619 |       "body_html": "Lighter by nature. Meet the SuperLight Wool Runner \u2013 an everyday sneaker engineered with an ultralight upper and our new revolutionary SuperLight Foam technology for a barely-there feel, and light-as-air fit that\u2019s our lightest and lowest carbon footprint to date. And we\u2019re just getting started\u2026.",
 620 |       "published_at": "2024-08-19T15:15:23-07:00",
 621 |       "created_at": "2023-08-09T19:57:33-07:00",
 622 |       "updated_at": "2024-08-24T17:56:38-07:00",
 623 |       "vendor": "Manybirds",
 624 |       "product_type": "Shoes",
 625 |       "tags": [
 626 |         "Manybirds::carbon-score = 4.03",
 627 |         "Manybirds::cfId = color-mens-super-light-wool-runners-dark-grey-medium-grey",
 628 |         "Manybirds::complete = true",
 629 |         "Manybirds::edition = classic",
 630 |         "Manybirds::gender = mens",
 631 |         "Manybirds::hue = grey",
 632 |         "Manybirds::master = mens-superlight-wool-runners",
 633 |         "Manybirds::material = wool",
 634 |         "Manybirds::price-tier = msrp",
 635 |         "Manybirds::silhouette = runner",
 636 |         "loop::returnable = true",
 637 |         "shoprunner",
 638 |         "YCRF_mens-move-shoes",
 639 |         "YGroup_ygroup_mens-superlight-wool-runners"
 640 |       ],
 641 |       "variants": [
 642 |         {
 643 |           "id": 40260974084176,
 644 |           "title": "8",
 645 |           "option1": "8",
 646 |           "option2": null,
 647 |           "option3": null,
 648 |           "sku": "A10668M080",
 649 |           "requires_shipping": true,
 650 |           "taxable": true,
 651 |           "featured_image": null,
 652 |           "available": true,
 653 |           "price": "120.00",
 654 |           "grams": 498,
 655 |           "compare_at_price": null,
 656 |           "position": 1,
 657 |           "product_id": 6864490004560,
 658 |           "created_at": "2023-08-09T19:57:33-07:00",
 659 |           "updated_at": "2024-08-24T17:56:38-07:00"
 660 |         },
 661 |         {
 662 |           "id": 40260974116944,
 663 |           "title": "9",
 664 |           "option1": "9",
 665 |           "option2": null,
 666 |           "option3": null,
 667 |           "sku": "A10668M090",
 668 |           "requires_shipping": true,
 669 |           "taxable": true,
 670 |           "featured_image": null,
 671 |           "available": true,
 672 |           "price": "120.00",
 673 |           "grams": 535,
 674 |           "compare_at_price": null,
 675 |           "position": 2,
 676 |           "product_id": 6864490004560,
 677 |           "created_at": "2023-08-09T19:57:33-07:00",
 678 |           "updated_at": "2024-08-24T17:56:38-07:00"
 679 |         },
 680 |         {
 681 |           "id": 40260974149712,
 682 |           "title": "10",
 683 |           "option1": "10",
 684 |           "option2": null,
 685 |           "option3": null,
 686 |           "sku": "A10668M100",
 687 |           "requires_shipping": true,
 688 |           "taxable": true,
 689 |           "featured_image": null,
 690 |           "available": true,
 691 |           "price": "120.00",
 692 |           "grams": 560,
 693 |           "compare_at_price": null,
 694 |           "position": 3,
 695 |           "product_id": 6864490004560,
 696 |           "created_at": "2023-08-09T19:57:33-07:00",
 697 |           "updated_at": "2024-08-24T17:56:38-07:00"
 698 |         },
 699 |         {
 700 |           "id": 40260974182480,
 701 |           "title": "11",
 702 |           "option1": "11",
 703 |           "option2": null,
 704 |           "option3": null,
 705 |           "sku": "A10668M110",
 706 |           "requires_shipping": true,
 707 |           "taxable": true,
 708 |           "featured_image": null,
 709 |           "available": true,
 710 |           "price": "120.00",
 711 |           "grams": 579,
 712 |           "compare_at_price": null,
 713 |           "position": 4,
 714 |           "product_id": 6864490004560,
 715 |           "created_at": "2023-08-09T19:57:33-07:00",
 716 |           "updated_at": "2024-08-24T17:56:38-07:00"
 717 |         },
 718 |         {
 719 |           "id": 40260974215248,
 720 |           "title": "12",
 721 |           "option1": "12",
 722 |           "option2": null,
 723 |           "option3": null,
 724 |           "sku": "A10668M120",
 725 |           "requires_shipping": true,
 726 |           "taxable": true,
 727 |           "featured_image": null,
 728 |           "available": true,
 729 |           "price": "120.00",
 730 |           "grams": 642,
 731 |           "compare_at_price": null,
 732 |           "position": 5,
 733 |           "product_id": 6864490004560,
 734 |           "created_at": "2023-08-09T19:57:33-07:00",
 735 |           "updated_at": "2024-08-24T17:56:38-07:00"
 736 |         },
 737 |         {
 738 |           "id": 40260974248016,
 739 |           "title": "13",
 740 |           "option1": "13",
 741 |           "option2": null,
 742 |           "option3": null,
 743 |           "sku": "A10668M130",
 744 |           "requires_shipping": true,
 745 |           "taxable": true,
 746 |           "featured_image": null,
 747 |           "available": true,
 748 |           "price": "120.00",
 749 |           "grams": 664,
 750 |           "compare_at_price": null,
 751 |           "position": 6,
 752 |           "product_id": 6864490004560,
 753 |           "created_at": "2023-08-09T19:57:33-07:00",
 754 |           "updated_at": "2024-08-24T17:56:38-07:00"
 755 |         },
 756 |         {
 757 |           "id": 40260974280784,
 758 |           "title": "14",
 759 |           "option1": "14",
 760 |           "option2": null,
 761 |           "option3": null,
 762 |           "sku": "A10668M140",
 763 |           "requires_shipping": true,
 764 |           "taxable": true,
 765 |           "featured_image": null,
 766 |           "available": true,
 767 |           "price": "120.00",
 768 |           "grams": 678,
 769 |           "compare_at_price": null,
 770 |           "position": 7,
 771 |           "product_id": 6864490004560,
 772 |           "created_at": "2023-08-09T19:57:33-07:00",
 773 |           "updated_at": "2024-08-24T17:56:38-07:00"
 774 |         }
 775 |       ],
 776 |       "images": [
 777 |         {
 778 |           "id": 32365862060112,
 779 |           "created_at": "2024-08-13T11:59:28-07:00",
 780 |           "position": 1,
 781 |           "updated_at": "2024-08-13T11:59:28-07:00",
 782 |           "product_id": 6864490004560,
 783 |           "variant_ids": [],
 784 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10669_24Q3_SuperLight_WR_Dark_Grey_Medium_Grey_PDP_SINGLE_3Q-2000x2000_f11911c8-d949-4291-9646-5dfa20506abe.png?v=1723575568",
 785 |           "width": 2000,
 786 |           "height": 2000
 787 |         },
 788 |         {
 789 |           "id": 32365862092880,
 790 |           "created_at": "2024-08-13T11:59:28-07:00",
 791 |           "position": 2,
 792 |           "updated_at": "2024-08-13T11:59:28-07:00",
 793 |           "product_id": 6864490004560,
 794 |           "variant_ids": [],
 795 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10669_24Q3_SuperLight_WR_Dark_Grey_Medium_Grey_PDP_LEFT-2000x2000_51940ffa-25a8-4037-bfcf-359d1c6f9259.png?v=1723575568",
 796 |           "width": 2000,
 797 |           "height": 2000
 798 |         },
 799 |         {
 800 |           "id": 32365862125648,
 801 |           "created_at": "2024-08-13T11:59:28-07:00",
 802 |           "position": 3,
 803 |           "updated_at": "2024-08-13T11:59:28-07:00",
 804 |           "product_id": 6864490004560,
 805 |           "variant_ids": [],
 806 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10669_24Q3_SuperLight_WR_Dark_Grey_Medium_Grey_PDP_BACK-2000x2000_811af23d-dca2-452a-9370-6eb8aa6847b2.png?v=1723575568",
 807 |           "width": 2000,
 808 |           "height": 2000
 809 |         },
 810 |         {
 811 |           "id": 32365862158416,
 812 |           "created_at": "2024-08-13T11:59:28-07:00",
 813 |           "position": 4,
 814 |           "updated_at": "2024-08-13T11:59:28-07:00",
 815 |           "product_id": 6864490004560,
 816 |           "variant_ids": [],
 817 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10669_24Q3_SuperLight_WR_Dark_Grey_Medium_Grey_PDP_TD-2000x2000_f1643699-e8d8-4419-adc1-02701aa4e5bd.png?v=1723575568",
 818 |           "width": 2000,
 819 |           "height": 2000
 820 |         },
 821 |         {
 822 |           "id": 32365862191184,
 823 |           "created_at": "2024-08-13T11:59:28-07:00",
 824 |           "position": 5,
 825 |           "updated_at": "2024-08-13T11:59:28-07:00",
 826 |           "product_id": 6864490004560,
 827 |           "variant_ids": [],
 828 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10669_24Q3_SuperLight_WR_Dark_Grey_Medium_Grey_PDP_SOLE-2000x2000_1dccbf00-9cc1-4223-81b3-6d15c697630e.png?v=1723575568",
 829 |           "width": 2000,
 830 |           "height": 2000
 831 |         },
 832 |         {
 833 |           "id": 32365862223952,
 834 |           "created_at": "2024-08-13T11:59:28-07:00",
 835 |           "position": 6,
 836 |           "updated_at": "2024-08-13T11:59:28-07:00",
 837 |           "product_id": 6864490004560,
 838 |           "variant_ids": [],
 839 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10669_24Q3_SuperLight_WR_Dark_Grey_Medium_Grey_PDP_PAIR_3Q-2000x2000_529013c3-128b-4cf7-86c2-1ed204f8d3e2.png?v=1723575568",
 840 |           "width": 2000,
 841 |           "height": 2000
 842 |         }
 843 |       ],
 844 |       "options": [
 845 |         {
 846 |           "name": "Size",
 847 |           "position": 1,
 848 |           "values": [
 849 |             "8",
 850 |             "9",
 851 |             "10",
 852 |             "11",
 853 |             "12",
 854 |             "13",
 855 |             "14"
 856 |           ]
 857 |         }
 858 |       ]
 859 |     },
 860 |     {
 861 |       "id": 7082686742608,
 862 |       "title": "Women's Tree Breezers Knit - Rugged Beige (Hazy Beige Sole)",
 863 |       "handle": "womens-tree-breezers-rugged-beige-knit",
 864 |       "body_html": "Crafted with silky-smooth, breathable eucalyptus tree fiber and a secure fitted collar, the Tree Breezer is a versatile, lightweight, and comfortable ballet flat with no break-in necessary.",
 865 |       "published_at": "2024-08-19T15:15:22-07:00",
 866 |       "created_at": "2024-07-08T16:26:01-07:00",
 867 |       "updated_at": "2024-08-24T17:56:38-07:00",
 868 |       "vendor": "Manybirds",
 869 |       "product_type": "Shoes",
 870 |       "tags": [
 871 |         "Manybirds::carbon-score = 2.93",
 872 |         "Manybirds::cfId = color-womens-tree-breezers-rugged-beige-hazy-beige",
 873 |         "Manybirds::complete = true",
 874 |         "Manybirds::edition = limited",
 875 |         "Manybirds::gender = womens",
 876 |         "Manybirds::hue = beige",
 877 |         "Manybirds::master = womens-tree-breezers",
 878 |         "Manybirds::material = tree",
 879 |         "Manybirds::price-tier = msrp",
 880 |         "Manybirds::silhouette = breezer",
 881 |         "loop::returnable = true",
 882 |         "shoprunner",
 883 |         "YCRF_womens-move-shoes-half-sizes",
 884 |         "YGroup_ygroup_womens-tree-breezers"
 885 |       ],
 886 |       "variants": [
 887 |         {
 888 |           "id": 40832464322640,
 889 |           "title": "5",
 890 |           "option1": "5",
 891 |           "option2": null,
 892 |           "option3": null,
 893 |           "sku": "A10938W050",
 894 |           "requires_shipping": true,
 895 |           "taxable": true,
 896 |           "featured_image": null,
 897 |           "available": true,
 898 |           "price": "100.00",
 899 |           "grams": 331,
 900 |           "compare_at_price": null,
 901 |           "position": 1,
 902 |           "product_id": 7082686742608,
 903 |           "created_at": "2024-07-08T16:26:01-07:00",
 904 |           "updated_at": "2024-08-24T17:56:38-07:00"
 905 |         },
 906 |         {
 907 |           "id": 40832464355408,
 908 |           "title": "5.5",
 909 |           "option1": "5.5",
 910 |           "option2": null,
 911 |           "option3": null,
 912 |           "sku": "A10938W055",
 913 |           "requires_shipping": true,
 914 |           "taxable": true,
 915 |           "featured_image": null,
 916 |           "available": true,
 917 |           "price": "100.00",
 918 |           "grams": 341,
 919 |           "compare_at_price": null,
 920 |           "position": 2,
 921 |           "product_id": 7082686742608,
 922 |           "created_at": "2024-07-08T16:26:01-07:00",
 923 |           "updated_at": "2024-08-24T17:56:38-07:00"
 924 |         },
 925 |         {
 926 |           "id": 40832464388176,
 927 |           "title": "6",
 928 |           "option1": "6",
 929 |           "option2": null,
 930 |           "option3": null,
 931 |           "sku": "A10938W060",
 932 |           "requires_shipping": true,
 933 |           "taxable": true,
 934 |           "featured_image": null,
 935 |           "available": true,
 936 |           "price": "100.00",
 937 |           "grams": 351,
 938 |           "compare_at_price": null,
 939 |           "position": 3,
 940 |           "product_id": 7082686742608,
 941 |           "created_at": "2024-07-08T16:26:01-07:00",
 942 |           "updated_at": "2024-08-24T17:56:38-07:00"
 943 |         },
 944 |         {
 945 |           "id": 40832464420944,
 946 |           "title": "6.5",
 947 |           "option1": "6.5",
 948 |           "option2": null,
 949 |           "option3": null,
 950 |           "sku": "A10938W065",
 951 |           "requires_shipping": true,
 952 |           "taxable": true,
 953 |           "featured_image": null,
 954 |           "available": true,
 955 |           "price": "100.00",
 956 |           "grams": 361,
 957 |           "compare_at_price": null,
 958 |           "position": 4,
 959 |           "product_id": 7082686742608,
 960 |           "created_at": "2024-07-08T16:26:01-07:00",
 961 |           "updated_at": "2024-08-24T17:56:38-07:00"
 962 |         },
 963 |         {
 964 |           "id": 40832464453712,
 965 |           "title": "7",
 966 |           "option1": "7",
 967 |           "option2": null,
 968 |           "option3": null,
 969 |           "sku": "A10938W070",
 970 |           "requires_shipping": true,
 971 |           "taxable": true,
 972 |           "featured_image": null,
 973 |           "available": true,
 974 |           "price": "100.00",
 975 |           "grams": 371,
 976 |           "compare_at_price": null,
 977 |           "position": 5,
 978 |           "product_id": 7082686742608,
 979 |           "created_at": "2024-07-08T16:26:01-07:00",
 980 |           "updated_at": "2024-08-24T17:56:38-07:00"
 981 |         },
 982 |         {
 983 |           "id": 40832464486480,
 984 |           "title": "7.5",
 985 |           "option1": "7.5",
 986 |           "option2": null,
 987 |           "option3": null,
 988 |           "sku": "A10938W075",
 989 |           "requires_shipping": true,
 990 |           "taxable": true,
 991 |           "featured_image": null,
 992 |           "available": true,
 993 |           "price": "100.00",
 994 |           "grams": 381,
 995 |           "compare_at_price": null,
 996 |           "position": 6,
 997 |           "product_id": 7082686742608,
 998 |           "created_at": "2024-07-08T16:26:01-07:00",
 999 |           "updated_at": "2024-08-24T17:56:38-07:00"
1000 |         },
1001 |         {
1002 |           "id": 40832464519248,
1003 |           "title": "8",
1004 |           "option1": "8",
1005 |           "option2": null,
1006 |           "option3": null,
1007 |           "sku": "A10938W080",
1008 |           "requires_shipping": true,
1009 |           "taxable": true,
1010 |           "featured_image": null,
1011 |           "available": true,
1012 |           "price": "100.00",
1013 |           "grams": 391,
1014 |           "compare_at_price": null,
1015 |           "position": 7,
1016 |           "product_id": 7082686742608,
1017 |           "created_at": "2024-07-08T16:26:01-07:00",
1018 |           "updated_at": "2024-08-24T17:56:38-07:00"
1019 |         },
1020 |         {
1021 |           "id": 40832464552016,
1022 |           "title": "8.5",
1023 |           "option1": "8.5",
1024 |           "option2": null,
1025 |           "option3": null,
1026 |           "sku": "A10938W085",
1027 |           "requires_shipping": true,
1028 |           "taxable": true,
1029 |           "featured_image": null,
1030 |           "available": true,
1031 |           "price": "100.00",
1032 |           "grams": 401,
1033 |           "compare_at_price": null,
1034 |           "position": 8,
1035 |           "product_id": 7082686742608,
1036 |           "created_at": "2024-07-08T16:26:01-07:00",
1037 |           "updated_at": "2024-08-24T17:56:38-07:00"
1038 |         },
1039 |         {
1040 |           "id": 40832464584784,
1041 |           "title": "9",
1042 |           "option1": "9",
1043 |           "option2": null,
1044 |           "option3": null,
1045 |           "sku": "A10938W090",
1046 |           "requires_shipping": true,
1047 |           "taxable": true,
1048 |           "featured_image": null,
1049 |           "available": true,
1050 |           "price": "100.00",
1051 |           "grams": 416,
1052 |           "compare_at_price": null,
1053 |           "position": 9,
1054 |           "product_id": 7082686742608,
1055 |           "created_at": "2024-07-08T16:26:01-07:00",
1056 |           "updated_at": "2024-08-24T17:56:38-07:00"
1057 |         },
1058 |         {
1059 |           "id": 40832464617552,
1060 |           "title": "9.5",
1061 |           "option1": "9.5",
1062 |           "option2": null,
1063 |           "option3": null,
1064 |           "sku": "A10938W095",
1065 |           "requires_shipping": true,
1066 |           "taxable": true,
1067 |           "featured_image": null,
1068 |           "available": true,
1069 |           "price": "100.00",
1070 |           "grams": 426,
1071 |           "compare_at_price": null,
1072 |           "position": 10,
1073 |           "product_id": 7082686742608,
1074 |           "created_at": "2024-07-08T16:26:01-07:00",
1075 |           "updated_at": "2024-08-24T17:56:38-07:00"
1076 |         },
1077 |         {
1078 |           "id": 40832464650320,
1079 |           "title": "10",
1080 |           "option1": "10",
1081 |           "option2": null,
1082 |           "option3": null,
1083 |           "sku": "A10938W100",
1084 |           "requires_shipping": true,
1085 |           "taxable": true,
1086 |           "featured_image": null,
1087 |           "available": true,
1088 |           "price": "100.00",
1089 |           "grams": 436,
1090 |           "compare_at_price": null,
1091 |           "position": 11,
1092 |           "product_id": 7082686742608,
1093 |           "created_at": "2024-07-08T16:26:01-07:00",
1094 |           "updated_at": "2024-08-24T17:56:38-07:00"
1095 |         },
1096 |         {
1097 |           "id": 40832464683088,
1098 |           "title": "10.5",
1099 |           "option1": "10.5",
1100 |           "option2": null,
1101 |           "option3": null,
1102 |           "sku": "A10938W105",
1103 |           "requires_shipping": true,
1104 |           "taxable": true,
1105 |           "featured_image": null,
1106 |           "available": true,
1107 |           "price": "100.00",
1108 |           "grams": 446,
1109 |           "compare_at_price": null,
1110 |           "position": 12,
1111 |           "product_id": 7082686742608,
1112 |           "created_at": "2024-07-08T16:26:01-07:00",
1113 |           "updated_at": "2024-08-24T17:56:38-07:00"
1114 |         },
1115 |         {
1116 |           "id": 40832464715856,
1117 |           "title": "11",
1118 |           "option1": "11",
1119 |           "option2": null,
1120 |           "option3": null,
1121 |           "sku": "A10938W110",
1122 |           "requires_shipping": true,
1123 |           "taxable": true,
1124 |           "featured_image": null,
1125 |           "available": true,
1126 |           "price": "100.00",
1127 |           "grams": 456,
1128 |           "compare_at_price": null,
1129 |           "position": 13,
1130 |           "product_id": 7082686742608,
1131 |           "created_at": "2024-07-08T16:26:01-07:00",
1132 |           "updated_at": "2024-08-24T17:56:38-07:00"
1133 |         }
1134 |       ],
1135 |       "images": [
1136 |         {
1137 |           "id": 32367931359312,
1138 |           "created_at": "2024-08-14T10:03:51-07:00",
1139 |           "position": 1,
1140 |           "updated_at": "2024-08-14T10:03:51-07:00",
1141 |           "product_id": 7082686742608,
1142 |           "variant_ids": [],
1143 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10938_24Q3_Tree_Breezer_Knit_Pack_Rugged_Beige_Hazy_Beige_SINGLE_3Q-2000x2000.png?v=1723655031",
1144 |           "width": 2000,
1145 |           "height": 2000
1146 |         },
1147 |         {
1148 |           "id": 32367931392080,
1149 |           "created_at": "2024-08-14T10:03:51-07:00",
1150 |           "position": 2,
1151 |           "updated_at": "2024-08-14T10:03:51-07:00",
1152 |           "product_id": 7082686742608,
1153 |           "variant_ids": [],
1154 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10938_24Q3_Tree_Breezer_Knit_Pack_Rugged_Beige_Hazy_Beige_LEFT-2000x2000.png?v=1723655031",
1155 |           "width": 2000,
1156 |           "height": 2000
1157 |         },
1158 |         {
1159 |           "id": 32367931424848,
1160 |           "created_at": "2024-08-14T10:03:51-07:00",
1161 |           "position": 3,
1162 |           "updated_at": "2024-08-14T10:03:51-07:00",
1163 |           "product_id": 7082686742608,
1164 |           "variant_ids": [],
1165 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10938_24Q3_Tree_Breezer_Knit_Pack_Rugged_Beige_Hazy_Beige_BACK-2000x2000.png?v=1723655031",
1166 |           "width": 2000,
1167 |           "height": 2000
1168 |         },
1169 |         {
1170 |           "id": 32367931457616,
1171 |           "created_at": "2024-08-14T10:03:51-07:00",
1172 |           "position": 4,
1173 |           "updated_at": "2024-08-14T10:03:51-07:00",
1174 |           "product_id": 7082686742608,
1175 |           "variant_ids": [],
1176 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10938_24Q3_Tree_Breezer_Knit_Pack_Rugged_Beige_Hazy_Beige_TD-2000x2000.png?v=1723655031",
1177 |           "width": 2000,
1178 |           "height": 2000
1179 |         },
1180 |         {
1181 |           "id": 32367931490384,
1182 |           "created_at": "2024-08-14T10:03:51-07:00",
1183 |           "position": 5,
1184 |           "updated_at": "2024-08-14T10:03:51-07:00",
1185 |           "product_id": 7082686742608,
1186 |           "variant_ids": [],
1187 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10938_24Q3_Tree_Breezer_Knit_Pack_Rugged_Beige_Hazy_Beige_SOLE-2000x2000.png?v=1723655031",
1188 |           "width": 2000,
1189 |           "height": 2000
1190 |         },
1191 |         {
1192 |           "id": 32367931523152,
1193 |           "created_at": "2024-08-14T10:03:51-07:00",
1194 |           "position": 6,
1195 |           "updated_at": "2024-08-14T10:03:51-07:00",
1196 |           "product_id": 7082686742608,
1197 |           "variant_ids": [],
1198 |           "src": "https:\/\/cdn.shopify.com\/s\/files\/1\/1104\/4168\/files\/A10938_24Q3_Tree_Breezer_Knit_Pack_Rugged_Beige_Hazy_Beige_PAIR_3Q-2000x2000.png?v=1723655031",
1199 |           "width": 2000,
1200 |           "height": 2000
1201 |         }
1202 |       ],
1203 |       "options": [
1204 |         {
1205 |           "name": "Size",
1206 |           "position": 1,
1207 |           "values": [
1208 |             "5",
1209 |             "5.5",
1210 |             "6",
1211 |             "6.5",
1212 |             "7",
1213 |             "7.5",
1214 |             "8",
1215 |             "8.5",
1216 |             "9",
1217 |             "9.5",
1218 |             "10",
1219 |             "10.5",
1220 |             "11"
1221 |           ]
1222 |         }
1223 |       ]
1224 |     }
1225 |   ]
1226 | }
```
Page 9/12FirstPrevNextLast