mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
205 lines
6.4 KiB
Python
205 lines
6.4 KiB
Python
"""Tests for the MCP server implementation using FastAPI TestClient."""
|
|
|
|
import pytest
|
|
from mcp.shared.exceptions import McpError
|
|
from mcp.types import INVALID_PARAMS
|
|
|
|
from basic_memory.mcp.server import handle_call_tool
|
|
from basic_memory.schemas import CreateEntityResponse, SearchNodesResponse
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_tool_name(app):
|
|
"""Test calling a non-existent tool."""
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("not_a_tool", {})
|
|
assert "Unknown tool" in str(exc.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_required_field(app):
|
|
"""Test validation when required fields are missing."""
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("search_nodes", {})
|
|
assert "query" in str(exc.value).lower()
|
|
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("create_entities", {})
|
|
assert "entities" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_arrays(app):
|
|
"""Test validation of array fields that can't be empty."""
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("create_entities", {"entities": []})
|
|
assert INVALID_PARAMS == exc.value.args[0]
|
|
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("open_nodes", {"entity_ids": []})
|
|
assert INVALID_PARAMS == exc.value.args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_field_types(app):
|
|
"""Test validation when fields have wrong types."""
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("search_nodes", {"query": 123})
|
|
assert "str" in str(exc.value).lower()
|
|
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool("create_entities", {"entities": "not an array"})
|
|
assert "array" in str(exc.value).lower() or "list" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_nested_fields(app):
|
|
"""Test validation of nested object fields."""
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool(
|
|
"create_entities",
|
|
{
|
|
"entities": [
|
|
{
|
|
"name": "Test",
|
|
# Missing required entity_type
|
|
"observations": [],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert "entity_type" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_relation_format_to_id(app):
|
|
"""Test validation of relation data."""
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool(
|
|
"create_relations",
|
|
{
|
|
"relations": [
|
|
{
|
|
"from_id": "test/entity1",
|
|
# Missing to_id
|
|
"relation_type": "relates_to",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert "to_id" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_relation_format_relation_type(app):
|
|
# Invalid relation type
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool(
|
|
"create_relations",
|
|
{
|
|
"relations": [
|
|
{
|
|
"from_id": "test/entity1",
|
|
"to_id": "test/entity2",
|
|
"relation_type": "", # Empty relation type
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert "relation_type" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_observation_validation_len(app):
|
|
"""Test validation specific to observations."""
|
|
# Empty observations
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool(
|
|
"add_observations",
|
|
{
|
|
"entity_id": "test/entity1",
|
|
"observations": ["", ""], # Empty observations
|
|
},
|
|
)
|
|
assert "observations" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_observation_validation_delete(app):
|
|
# Empty deletions
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool(
|
|
"delete_observations",
|
|
{
|
|
"entity_id": "test/entity1",
|
|
"deletions": [], # Empty deletions
|
|
},
|
|
)
|
|
assert INVALID_PARAMS == exc.value.args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edge_case_validation_search_len(app):
|
|
"""Test edge cases in validation."""
|
|
# Very long strings
|
|
with pytest.raises(McpError) as exc:
|
|
await handle_call_tool(
|
|
"search_nodes",
|
|
{"query": "x" * 10000}, # Extremely long query
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edge_case_validation_name_sanitization(app):
|
|
"""Test that entity names are properly sanitized for IDs."""
|
|
# Test cases for different sanitization scenarios
|
|
test_cases = [
|
|
{
|
|
"name": "🧪 FOO & File (1)", # Emoji and special chars
|
|
"expected_id": "test/foo_file_1",
|
|
},
|
|
{
|
|
"name": "BARR Multiple Spaces", # Multiple spaces
|
|
"expected_id": "test/barr_multiple_spaces",
|
|
},
|
|
{
|
|
"name": "LOTSOF@#$Special&*Chars", # Special characters
|
|
"expected_id": "test/lotsofspecialchars",
|
|
},
|
|
{
|
|
"name": "\x00null", # Null byte
|
|
"expected_id": "test/null",
|
|
},
|
|
{
|
|
"name": "\nline", # Newline
|
|
"expected_id": "test/line",
|
|
},
|
|
]
|
|
|
|
for test in test_cases:
|
|
result = await handle_call_tool(
|
|
"create_entities",
|
|
{
|
|
"entities": [
|
|
{
|
|
"name": test["name"],
|
|
"entity_type": "test",
|
|
"observations": [],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
response = CreateEntityResponse.model_validate_json(result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
|
|
entity = response.entities[0]
|
|
|
|
# Original name should be preserved
|
|
assert entity.name == test["name"]
|
|
# ID should be sanitized
|
|
assert entity.id == test["expected_id"]
|
|
|
|
# Verify we can find it with original name
|
|
search_result = await handle_call_tool("search_nodes", {"query": test["name"]})
|
|
search_response = SearchNodesResponse.model_validate_json(search_result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
|
|
assert len(search_response.matches) > 0
|