项目文件夹

文件
wehub-resource-sync 4a19d70af1
Lint with Ruff / ruff (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
MCP Server Tests / live-mcp-tests (push) Has been cancelled
Server Tests / live-server-tests (push) Has been cancelled
Pyright Type Check / pyright (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
Tests / database-integration-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:54 +08:00

262 行
9.9 KiB
Python

"""Live end-to-end tests for the Graphiti MCP server against FalkorDB + a real LLM.
These tests start the MCP server as a subprocess over stdio, backed by FalkorDB
(the default database) and a real OpenAI model, and exercise the tools end to end:
add_memory -> wait for async processing -> search_nodes / search_memory_facts ->
get_episodes / get_status -> delete_episode -> clear_graph.
They are skipped automatically unless an OpenAI API key is available AND FalkorDB
is reachable:
- Local: the project-root ``.env`` is loaded, so an ``OPENAI_API_KEY`` there is
picked up. Start FalkorDB first, e.g.::
docker run -d --name falkordb -p 6379:6379 falkordb/falkordb:latest
cd mcp_server && uv run pytest tests/test_live_falkordb_int.py
- CI: ``OPENAI_API_KEY`` comes from the GitHub environment and FalkorDB runs as a
container (see .github/workflows/mcp-server-tests.yml).
The model is taken from ``MODEL_NAME`` (CI pins a lighter, broadly-available model
for speed/reliability; gpt-5.5 is the server's runtime default but needs a
key with fast gpt-5.5 access), falling back to the server's configured default.
"""
import asyncio
import json
import os
import socket
import time
from contextlib import AsyncExitStack, suppress
from pathlib import Path
from typing import Any
import pytest
from dotenv import load_dotenv
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Load the project-root .env for local runs (CI provides the env directly).
_REPO_ROOT = Path(__file__).resolve().parents[2]
load_dotenv(_REPO_ROOT / '.env')
MCP_SERVER_DIR = Path(__file__).resolve().parent.parent
FALKORDB_URI = os.environ.get('FALKORDB_URI', 'redis://localhost:6379')
pytestmark = [
pytest.mark.integration,
pytest.mark.requires_falkordb,
pytest.mark.requires_openai,
]
def _falkordb_reachable(uri: str) -> bool:
"""Best-effort TCP check that a FalkorDB (Redis) endpoint is reachable."""
host, port = 'localhost', 6379
if '://' in uri:
rest = uri.split('://', 1)[1].split('@')[-1] # strip scheme + any credentials
hostport = rest.split('/')[0]
if ':' in hostport:
host_part, port_part = hostport.rsplit(':', 1)
host = host_part or host
with suppress(ValueError):
port = int(port_part)
elif hostport:
host = hostport
try:
with socket.create_connection((host, port), timeout=2):
return True
except OSError:
return False
# Skip the whole module unless prerequisites are present, so the suite is a no-op
# locally without setup and on fork PRs (which have no secrets).
if not os.environ.get('OPENAI_API_KEY'):
pytest.skip('OPENAI_API_KEY not set; skipping live MCP tests', allow_module_level=True)
if not _falkordb_reachable(FALKORDB_URI):
pytest.skip(
f'FalkorDB not reachable at {FALKORDB_URI}; skipping live MCP tests',
allow_module_level=True,
)
def _unique_group_id() -> str:
# Alphanumeric only (no '_'/'-'): keeps the RediSearch fulltext group filter
# valid without relying on group_id escaping, so the suite is backend-portable.
return f'livetest{int(time.time())}{os.getpid()}'
def _raise_on_error(tool: str, resp: Any) -> None:
"""Surface a tool's error response immediately instead of masking it as empty.
Otherwise a real server/LLM/schema error only shows up as a generic
'not processed within the timeout' after the full poll, hiding the cause.
"""
if isinstance(resp, dict) and resp.get('error'):
raise AssertionError(f'{tool} returned an error: {resp["error"]}')
class LiveMCPClient:
"""Spawns the MCP server over stdio (FalkorDB backend) and calls its tools."""
def __init__(self, group_id: str):
self.group_id = group_id
self._stack = AsyncExitStack()
self.session: ClientSession | None = None
async def __aenter__(self) -> 'LiveMCPClient':
env = {
**os.environ,
'GRAPHITI_GROUP_ID': self.group_id,
'FALKORDB_URI': FALKORDB_URI,
}
params = StdioServerParameters(
command='uv',
args=['run', str(MCP_SERVER_DIR / 'main.py'), '--transport', 'stdio'],
env=env,
cwd=str(MCP_SERVER_DIR),
)
read, write = await self._stack.enter_async_context(stdio_client(params))
self.session = await self._stack.enter_async_context(ClientSession(read, write))
await self.session.initialize()
return self
async def __aexit__(self, *exc: Any) -> None:
await self._stack.aclose()
async def call(self, tool: str, arguments: dict[str, Any]) -> Any:
assert self.session is not None
result = await self.session.call_tool(tool, arguments)
if not result.content:
return None
text = getattr(result.content[0], 'text', None)
if text is None:
return None
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return text
async def list_tool_names(self) -> set[str]:
assert self.session is not None
result = await self.session.list_tools()
return {tool.name for tool in result.tools}
async def wait_for_episodes(
self, expected: int = 1, timeout: float = 180.0, poll: float = 3.0
) -> list[dict[str, Any]]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = await self.call(
'get_episodes', {'group_ids': [self.group_id], 'max_episodes': 50}
)
_raise_on_error('get_episodes', resp)
episodes = resp.get('episodes', []) if isinstance(resp, dict) else []
if len(episodes) >= expected:
return episodes
await asyncio.sleep(poll)
return []
async def search_until(
self,
tool: str,
arguments: dict[str, Any],
key: str,
attempts: int = 6,
poll: float = 3.0,
) -> list[Any]:
"""Call a search tool, retrying until ``resp[key]`` is non-empty (index lag)."""
results: list[Any] = []
for _ in range(attempts):
resp = await self.call(tool, arguments)
_raise_on_error(tool, resp)
results = resp.get(key, []) if isinstance(resp, dict) else []
if results:
return results
await asyncio.sleep(poll)
return results
async def test_server_lists_core_tools():
async with LiveMCPClient(_unique_group_id()) as client:
tools = await client.list_tool_names()
for expected in (
'add_memory',
'search_nodes',
'search_memory_facts',
'get_episodes',
'get_entity_edge',
'delete_entity_edge',
'delete_episode',
'clear_graph',
'get_status',
# parity tools (this branch)
'summarize_saga',
'build_communities',
'add_triplet',
'get_episode_entities',
):
assert expected in tools, f'missing tool {expected}; got {sorted(tools)}'
async def test_end_to_end_add_search_delete_clear():
group = _unique_group_id()
async with LiveMCPClient(group) as client:
try:
# 1. Add an episode with clearly-extractable entities and a relationship.
add = await client.call(
'add_memory',
{
'name': 'Live Test Episode',
'episode_body': (
'Alice is a software engineer at Acme Corporation. '
'She works on the Graphiti project.'
),
'source': 'text',
'source_description': 'live integration test',
'group_id': group,
},
)
assert isinstance(add, dict) and 'message' in add, (
f'unexpected add_memory response: {add}'
)
# 2. Wait for async processing to persist the episode (and its graph).
episodes = await client.wait_for_episodes(expected=1)
assert episodes, 'episode was not processed within the timeout'
assert any(e.get('group_id') == group for e in episodes)
episode_uuid = episodes[0]['uuid']
# 3. At least one entity was extracted and is searchable.
nodes = await client.search_until(
'search_nodes',
{'query': 'Alice Acme Graphiti', 'group_ids': [group], 'max_nodes': 10},
key='nodes',
)
assert nodes, 'expected at least one extracted entity node'
# 4. At least one fact (edge) was extracted and is searchable.
facts = await client.search_until(
'search_memory_facts',
{'query': 'Alice Acme Corporation', 'group_ids': [group], 'max_facts': 10},
key='facts',
)
assert facts, 'expected at least one extracted fact'
# 5. Status reports a healthy FalkorDB connection.
status = await client.call('get_status', {})
assert isinstance(status, dict) and status.get('status') == 'ok', f'status: {status}'
# 6. Delete the episode, then clear the test group.
deleted = await client.call('delete_episode', {'uuid': episode_uuid})
assert isinstance(deleted, dict) and 'message' in deleted, f'delete_episode: {deleted}'
cleared = await client.call('clear_graph', {'group_ids': [group]})
assert isinstance(cleared, dict) and 'message' in cleared, f'clear_graph: {cleared}'
finally:
# Always remove this run's data, even if an assertion above failed, so a
# long-lived local FalkorDB doesn't accumulate orphaned test groups.
with suppress(Exception):
await client.call('clear_graph', {'group_ids': [group]})