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 | }
```