refactor mcp server to use rest client

This commit is contained in:
phernandez
2024-12-14 21:24:32 -06:00
parent f698e8b102
commit 052ee403b3
2 changed files with 205 additions and 416 deletions
+74 -286
View File
@@ -1,355 +1,143 @@
"""MCP server implementation for basic-memory."""
import sys
"""MCP server implementation using FastAPI TestClient."""
import json
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, Dict, Any, Optional, Literal, Callable, Awaitable
from sqlalchemy.ext.asyncio import AsyncEngine
from typing_extensions import TypeAlias
from typing import List, Dict, Any
from httpx import AsyncClient, ASGITransport
from mcp import McpError
from mcp.server import Server
from mcp.types import Tool, EmbeddedResource, TextResourceContents, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR
from mcp.shared.exceptions import McpError
from pydantic.networks import AnyUrl
from pydantic import TypeAdapter, BaseModel
from mcp.types import Tool, EmbeddedResource, TextResourceContents
from pydantic import TypeAdapter, AnyUrl
from basic_memory import db
from basic_memory.config import ProjectConfig
from basic_memory.fileio import EntityNotFoundError
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.api.app import app as fastapi_app
from basic_memory.schemas import (
# Tool inputs
CreateEntityRequest, SearchNodesRequest, OpenNodesRequest,
CreateRelationsRequest, DeleteEntityRequest,
DeleteObservationsRequest,
# Tool responses
CreateEntityResponse, SearchNodesResponse, OpenNodesResponse,
AddObservationsResponse, CreateRelationsResponse, DeleteEntityResponse,
EntityResponse, ObservationResponse, Relation, AddObservationsRequest
AddObservationsRequest, CreateRelationsRequest, DeleteEntityRequest,
DeleteObservationsRequest
)
from basic_memory.services import EntityService, ObservationService, RelationService
from basic_memory.services.memory_service import MemoryService
from loguru import logger
MIME_TYPE = "application/vnd.basic-memory+json"
# URI constants
url_validator = TypeAdapter(AnyUrl)
BASIC_MEMORY_URI = url_validator.validate_python("basic-memory://response")
MIME_TYPE = "application/vnd.basic-memory+json"
# Define tool name type and handler type
ToolName = Literal[
"create_entities",
"search_nodes",
"open_nodes",
"add_observations",
"create_relations",
"delete_entities",
"delete_observations"
]
ToolHandler: TypeAlias = Callable[[MemoryService, Dict[str, Any]], Awaitable[EmbeddedResource]]
@asynccontextmanager
async def get_memory_service_session(engine: AsyncEngine, project_path: Path):
"""Get all services with proper session and lifecycle management."""
async with db.session(engine) as session:
# Create repos
entity_repo = EntityRepository(session)
observation_repo = ObservationRepository(session)
relation_repo = RelationRepository(session)
# Create services
entity_service = EntityService(project_path, entity_repo)
observation_service = ObservationService(project_path, observation_repo)
relation_service = RelationService(project_path, relation_repo)
# Create memory service
memory_service = MemoryService(
project_path=project_path,
entity_service=entity_service,
relation_service=relation_service,
observation_service=observation_service
)
yield memory_service
@asynccontextmanager
async def get_project_services(project_path: Path):
"""Get all services for a project with full lifecycle management."""
async with db.engine(project_path=project_path) as engine:
async with get_memory_service_session(engine, project_path) as services:
yield services
def create_response(response: BaseModel) -> EmbeddedResource:
"""Create standard MCP response from any response model."""
logger.debug(f"Creating MCP response from {response.__class__.__name__}")
result = EmbeddedResource(
def create_response(data: Dict[str, Any]) -> EmbeddedResource:
"""Create standard MCP response."""
return EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri=BASIC_MEMORY_URI,
mimeType=MIME_TYPE,
text=response.model_dump_json()
text=json.dumps(data),
)
)
logger.debug(f"Created response: {result}")
return result
async def handle_create_entities(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle create_entities tool call."""
# Validate input
logger.debug(f"Creating entities with args: {args}")
input_args = CreateEntityRequest.model_validate(args)
logger.debug(f"Validated input: {len(input_args.entities)} entities")
# Call service with validated data
entities = await service.create_entities(input_args.entities)
logger.debug(f"Created {len(entities)} entities")
# Format response
response = CreateEntityResponse(entities=[EntityResponse.model_validate(entity) for entity in entities])
logger.debug("Formatted create_entities response")
return create_response(response)
async def handle_search_nodes(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle search_nodes tool call."""
logger.debug(f"Searching nodes with query: {args.get('query')}")
input_args = SearchNodesRequest.model_validate(args)
results = await service.search_nodes(input_args.query)
logger.debug(f"Found {len(results)} matches for query '{input_args.query}'")
response = SearchNodesResponse(
matches=[EntityResponse.model_validate(entity) for entity in results],
query=input_args.query
)
return create_response(response)
async def handle_open_nodes(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle open_nodes tool call."""
logger.debug(f"Opening nodes: {args.get('names')}")
input_args = OpenNodesRequest.model_validate(args)
entities = await service.open_nodes(input_args.names)
logger.debug(f"Opened {len(entities)} entities")
response = OpenNodesResponse(entities=[EntityResponse.model_validate(entity) for entity in entities])
return create_response(response)
async def handle_add_observations(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle add_observations tool call."""
# Validate input
logger.debug(f"Adding observations: {args}")
input_args = AddObservationsRequest.model_validate(args)
logger.debug(f"Adding {len(input_args.observations)} observations to entity {input_args.entity_id}")
# Call service with validated data
observations = await service.add_observations(input_args)
logger.debug(f"Added {len(observations)} observations")
# Format response
response = AddObservationsResponse(
entity_id=input_args.entity_id,
observations=[ObservationResponse.model_validate(obs) for obs in observations]
)
return create_response(response)
async def handle_create_relations(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle create_relations tool call."""
# Validate input
logger.debug(f"Creating relations: {args}")
input_args = CreateRelationsRequest.model_validate(args)
logger.debug(f"Creating {len(input_args.relations)} relations")
# Call service with validated data
created = await service.create_relations(input_args.relations)
logger.debug(f"Created {len(created)} relations")
# Format response
response = CreateRelationsResponse(relations=[Relation.model_validate(relation) for relation in created])
return create_response(response)
async def handle_delete_entities(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle delete_entities tool call."""
logger.debug(f"Deleting entities: {args}")
input_args = DeleteEntityRequest.model_validate(args)
deleted = await service.delete_entities(input_args.names)
logger.debug(f"Deleted entities: {deleted}")
response = DeleteEntityResponse(deleted=deleted)
return create_response(response)
async def handle_delete_observations(
service: MemoryService,
args: Dict[str, Any]
) -> EmbeddedResource:
"""Handle delete_observations tool call."""
logger.debug(f"Deleting observations: {args}")
return EmbeddedResource()
# Map tool names to handlers
TOOL_HANDLERS: Dict[ToolName, ToolHandler] = {
"create_entities": handle_create_entities,
"search_nodes": handle_search_nodes,
"open_nodes": handle_open_nodes,
"add_observations": handle_add_observations,
"create_relations": handle_create_relations,
"delete_entities": handle_delete_entities,
"delete_observations": handle_delete_observations,
}
@asynccontextmanager
async def get_client():
"""Get FastAPI test client."""
async with AsyncClient(
transport=ASGITransport(app=fastapi_app),
base_url="http://test"
) as client:
yield client
class MemoryServer(Server):
"""Extended server class that exposes handlers for testing."""
def __init__(self, config: Optional[ProjectConfig] = None):
"""MCP server that forwards requests to FastAPI."""
def __init__(self):
super().__init__("basic-memory")
self.config = config or ProjectConfig()
logger.debug(f"Initialized MemoryServer with config: {self.config}")
self.register_handlers()
def register_handlers(self):
"""Register all handlers with proper decorators."""
"""Register all handlers."""
@self.list_tools()
async def handle_list_tools() -> List[Tool]:
"""Define the available tools."""
logger.debug("Listing available tools")
tools = [
return [
Tool(
name="create_entities",
description="Create multiple new entities in the knowledge graph",
description="Create multiple new entities",
inputSchema=CreateEntityRequest.model_json_schema()
),
Tool(
name="search_nodes",
description="Search for nodes in the knowledge graph",
name="search_nodes",
description="Search for nodes",
inputSchema=SearchNodesRequest.model_json_schema()
),
Tool(
name="open_nodes",
description="Open specific nodes by their names",
description="Open specific nodes",
inputSchema=OpenNodesRequest.model_json_schema()
),
Tool(
name="add_observations",
description="Add observations to existing entities",
description="Add observations",
inputSchema=AddObservationsRequest.model_json_schema()
),
Tool(
name="create_relations",
description="Create relations between entities",
description="Create relations",
inputSchema=CreateRelationsRequest.model_json_schema()
),
Tool(
name="delete_entities",
description="Delete entities from the knowledge graph",
description="Delete entities",
inputSchema=DeleteEntityRequest.model_json_schema()
),
Tool(
name="delete_observations",
description="Delete observations from entities",
description="Delete observations",
inputSchema=DeleteObservationsRequest.model_json_schema()
)
]
logger.debug(f"Returning {len(tools)} available tools")
return tools
@self.call_tool()
async def handle_call_tool(
name: str,
arguments: Dict[str, Any],
*,
memory_service: Optional[MemoryService] = None
name: str,
arguments: Dict[str, Any]
) -> List[EmbeddedResource]:
"""Handle tool calls by delegating to the memory service."""
try:
logger.debug(f"Handling tool call: {name} with args: {arguments}")
# Check if tool exists
if name not in TOOL_HANDLERS:
logger.error(f"Unknown tool requested: {name}")
raise McpError(METHOD_NOT_FOUND, f"Unknown tool: {name}")
"""Forward tool calls to FastAPI endpoints."""
# Map tools to FastAPI endpoints
handlers = {
"create_entities": lambda c, a: c.post("/knowledge/entities", json=a),
"search_nodes": lambda c, a: c.post("/knowledge/search", json=a),
"open_nodes": lambda c, a: c.post("/knowledge/nodes", json=a),
"add_observations": lambda c, a: c.post("/knowledge/observations", json=a),
"create_relations": lambda c, a: c.post("/knowledge/relations", json=a),
"delete_entities": lambda c, a: c.delete(f"/knowledge/entities/{a['names'][0]}"),
"delete_observations": lambda c, a: c.delete("/knowledge/observations", json=a)
}
# Get tool endpoint
handler = handlers.get(name)
if handler is None:
raise McpError(f"Unknown tool {name}")
# invoke the client handler function
async with get_client() as client:
response = await handler(client, arguments)
return [create_response(response.json())]
async with get_project_services(self.config.path) as service:
tool_name = name # type: ignore
result = [await TOOL_HANDLERS[tool_name](service, arguments)]
logger.debug(f"Tool {name} completed successfully")
return result
except ValueError as e:
logger.error(f"Invalid parameters for {name}: {e}")
raise McpError(INVALID_PARAMS, str(e))
except EntityNotFoundError as e:
logger.error(f"Entity not found in {name}: {e}")
raise McpError(INVALID_PARAMS, str(e))
except Exception as e:
logger.exception(f"Unexpected error in {name}: {e}")
raise McpError(INTERNAL_ERROR, str(e))
# Store handlers as instance attributes for testing
self.handle_list_tools = handle_list_tools
self.handle_call_tool = handle_call_tool
logger.debug("Registered all handlers")
# Create server instance with default config
# Create server instance
server = MemoryServer()
def setup_logging():
"""Configure logging for the application."""
# Remove default handler
logger.remove()
# Add file handler
logger.add(
"basic-memory-mcp.log",
rotation="100 MB",
level="DEBUG",
backtrace=True,
diagnose=True
)
# Add stdout handler for INFO and above
logger.add(
sys.stdout,
level="INFO",
backtrace=True,
diagnose=True
)
async def run_server():
"""Run the MCP server."""
from mcp.server.stdio import stdio_server
options = server.create_initialization_options()
logger.info(f"Starting MCP server {options.server_name}")
logger.info(f"Database URL: {server.config.database_url}")
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, options)
if __name__ == "__main__":
setup_logging()
import asyncio
from mcp.server.stdio import stdio_server
async def run_server():
"""Run the MCP server."""
options = server.create_initialization_options()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, options)
asyncio.run(run_server())
+131 -130
View File
@@ -1,21 +1,43 @@
"""Tests for the MCP server implementation."""
"""Tests for the MCP server implementation using FastAPI TestClient."""
import pytest
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from mcp.types import EmbeddedResource
from mcp.shared.exceptions import McpError
from basic_memory.mcp.server import MemoryServer, MIME_TYPE, BASIC_MEMORY_URI
from basic_memory.schemas import (
CreateEntityResponse, SearchNodesResponse, AddObservationsResponse,
)
from basic_memory.api.app import app as fastapi_app
from basic_memory.deps import get_project_config, get_engine
from basic_memory.schemas import CreateEntityResponse, SearchNodesResponse, AddObservationsResponse
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
def app(test_config, engine) -> FastAPI:
"""Create test FastAPI application."""
app = fastapi_app
app.dependency_overrides[get_project_config] = lambda: test_config
app.dependency_overrides[get_engine] = lambda: engine
return app
@pytest.fixture
async def client(app: FastAPI):
"""Create test client that both MCP and tests will use."""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
yield client
@pytest.fixture
def test_entity_data():
"""Sample data for creating a test entity using camelCase (like MCP will)."""
"""Sample data for creating a test entity."""
return {
"entities": [{
"name": "Test Entity",
@@ -25,14 +47,15 @@ def test_entity_data():
}]
}
@pytest.fixture
def test_directory_entity_data():
"""Real data that caused failure in the tool."""
return {
"entities": [{
"name": "Directory Organization",
"entity_type": "memory",
"description": "Implemented filesystem organization by entity type",
"name": "Directory Organization",
"entity_type": "memory",
"description": "Implemented filesystem organization by entity type",
"observations": [
"Files are now organized by type using directories like entities/project/basic_memory",
"Entity IDs match filesystem paths for better mental model",
@@ -41,213 +64,191 @@ def test_directory_entity_data():
}]
}
@pytest.fixture
def test_entity_snake_case():
"""Same test data but using snake_case to test schema flexibility."""
return {
"entities": [{
"name": "Test Entity",
"entity_type": "test",
"description": "", # Empty string instead of None
"observations": ["This is a test observation"]
}]
}
@pytest.mark.anyio
async def test_list_tools(test_config):
async def test_list_tools(app):
"""Test that server exposes expected tools."""
server_instance = MemoryServer(config=test_config)
tools = await server_instance.handle_list_tools()
server = MemoryServer()
tools = await server.handle_list_tools()
# Check each expected tool is present
expected_tools = {
"create_entities", "search_nodes", "open_nodes",
"add_observations", "create_relations",
"add_observations", "create_relations",
"delete_entities", "delete_observations"
}
found_tools = {t.name: t for t in tools}
assert found_tools.keys() == expected_tools
# Verify schemas include required fields
search_schema = found_tools["search_nodes"].inputSchema
assert "query" in search_schema["properties"]
assert search_schema["required"] == ["query"]
@pytest.mark.anyio
async def test_create_directory_entity(test_directory_entity_data, memory_service, test_config):
async def test_create_directory_entity(test_directory_entity_data, client):
"""Test creating entity with exactly the data that failed in the tool."""
server_instance = MemoryServer(config=test_config)
result = await server_instance.handle_call_tool(
"create_entities",
test_directory_entity_data,
memory_service=memory_service
server = MemoryServer()
result = await server.handle_call_tool(
"create_entities",
test_directory_entity_data
)
# Verify response format
assert len(result) == 1
assert isinstance(result[0], EmbeddedResource)
assert result[0].type == "resource"
# Verify entity creation
response = CreateEntityResponse.model_validate_json(result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
response = CreateEntityResponse.model_validate_json(result[0].resource.text)
assert len(response.entities) == 1
assert response.entities[0].name == "Directory Organization"
assert response.entities[0].entity_type == "memory"
assert len(response.entities[0].observations) == 3
created = response.entities[0]
assert created.name == "Directory Organization"
assert created.entity_type == "memory"
assert len(created.observations) == 3
# Verify entity exists through API
api_response = await client.get(f"/knowledge/entities/{created.id}")
assert api_response.status_code == 200
entity = api_response.json()
assert entity["name"] == "Directory Organization"
# noinspection DuplicatedCode
@pytest.mark.anyio
async def test_create_entities_snake_case(test_entity_snake_case, memory_service, test_config):
"""Test creating an entity with snake_case data (like internal usage)."""
server_instance = MemoryServer(config=test_config)
result = await server_instance.handle_call_tool(
"create_entities",
test_entity_snake_case,
memory_service=memory_service
)
assert len(result) == 1
assert isinstance(result[0], EmbeddedResource)
assert result[0].type == "resource"
assert isinstance(result[0].resource.uri, type(BASIC_MEMORY_URI))
assert str(result[0].resource.uri) == str(BASIC_MEMORY_URI)
assert result[0].resource.mimeType == MIME_TYPE
response = CreateEntityResponse.model_validate_json(result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
assert len(response.entities) == 1
assert response.entities[0].name == "Test Entity"
assert response.entities[0].entity_type == "test"
assert len(response.entities[0].observations) == 1
@pytest.mark.anyio
async def test_search_nodes(test_entity_data, memory_service, test_config):
async def test_search_nodes(test_entity_data, client):
"""Test searching for an entity after creating it."""
server_instance = MemoryServer(config=test_config)
server = MemoryServer()
# First create an entity
await server_instance.handle_call_tool(
"create_entities",
test_entity_data,
memory_service=memory_service
)
await server.handle_call_tool("create_entities", test_entity_data)
# Then search for it
result = await server_instance.handle_call_tool(
"search_nodes",
{"query": "Test Entity"},
memory_service=memory_service
result = await server.handle_call_tool(
"search_nodes",
{"query": "Test Entity"}
)
# Verify response format
assert len(result) == 1
assert isinstance(result[0], EmbeddedResource)
assert result[0].type == "resource"
assert isinstance(result[0].resource.uri, type(BASIC_MEMORY_URI))
assert str(result[0].resource.uri) == str(BASIC_MEMORY_URI)
assert result[0].resource.mimeType == MIME_TYPE
response = SearchNodesResponse.model_validate_json(result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
# Verify search results
response = SearchNodesResponse.model_validate_json(result[0].resource.text)
assert len(response.matches) == 1
assert response.matches[0].name == "Test Entity"
assert response.query == "Test Entity"
# Verify through API
api_response = await client.post("/knowledge/search", json={"query": "Test Entity"})
assert api_response.status_code == 200
data = api_response.json()
assert len(data["matches"]) == 1
assert data["matches"][0]["name"] == "Test Entity"
@pytest.mark.anyio
async def test_add_observations(test_entity_data, memory_service, test_config):
async def test_add_observations(test_entity_data, client):
"""Test adding observations to an existing entity."""
server_instance = MemoryServer(config=test_config)
# First create an entity and get its ID from response
create_result = await server_instance.handle_call_tool(
"create_entities",
test_entity_data,
memory_service=memory_service
)
create_response = CreateEntityResponse.model_validate_json(create_result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
server = MemoryServer()
# First create an entity
create_result = await server.handle_call_tool("create_entities", test_entity_data)
create_response = CreateEntityResponse.model_validate_json(create_result[0].resource.text)
entity_id = create_response.entities[0].id
# Add new observations using camelCase
result = await server_instance.handle_call_tool(
# Add new observation
result = await server.handle_call_tool(
"add_observations",
{
"entity_id": entity_id,
"observations": ["A new observation"]
},
memory_service=memory_service
}
)
# Verify response format
assert len(result) == 1
assert isinstance(result[0], EmbeddedResource)
assert result[0].type == "resource"
assert isinstance(result[0].resource.uri, type(BASIC_MEMORY_URI))
assert str(result[0].resource.uri) == str(BASIC_MEMORY_URI)
assert result[0].resource.mimeType == MIME_TYPE
response = AddObservationsResponse.model_validate_json(result[0].resource.text) # pyright: ignore [reportAttributeAccessIssue]
# Verify observation was added
response = AddObservationsResponse.model_validate_json(result[0].resource.text)
assert response.entity_id == entity_id
assert len(response.observations) == 1
assert response.observations[0].content == "A new observation"
# Verify through API
api_response = await client.get(f"/knowledge/entities/{entity_id}")
assert api_response.status_code == 200
entity = api_response.json()
assert len(entity["observations"]) == 2 # Original + new
assert "A new observation" in [o["content"] for o in entity["observations"]]
@pytest.mark.anyio
async def test_invalid_tool_name(test_config):
async def test_invalid_tool_name():
"""Test calling a non-existent tool."""
server_instance = MemoryServer(config=test_config)
server = MemoryServer()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("not_a_tool", {})
await server.handle_call_tool("not_a_tool", {})
assert "Unknown tool" in str(exc.value)
@pytest.mark.anyio
class TestInputValidation:
"""Test input validation for various tools."""
async def test_missing_required_field(self, test_config):
async def test_missing_required_field(self):
"""Test validation when required fields are missing."""
server_instance = MemoryServer(config=test_config)
server = MemoryServer()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("search_nodes", {})
await server.handle_call_tool("search_nodes", {})
assert "query" in str(exc.value).lower()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("create_entities", {})
await server.handle_call_tool("create_entities", {})
assert "entities" in str(exc.value).lower()
async def test_empty_arrays(self, test_config):
async def test_empty_arrays(self):
"""Test validation of array fields that can't be empty."""
server_instance = MemoryServer(config=test_config)
server = MemoryServer()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("create_entities", {"entities": []})
await server.handle_call_tool("create_entities", {"entities": []})
assert "validation error" in str(exc.value).lower()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("open_nodes", {"names": []})
await server.handle_call_tool("open_nodes", {"names": []})
assert "validation error" in str(exc.value).lower()
async def test_invalid_field_types(self, test_config):
async def test_invalid_field_types(self):
"""Test validation when fields have wrong types."""
server_instance = MemoryServer(config=test_config)
server = MemoryServer()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("search_nodes", {"query": 123})
await server.handle_call_tool("search_nodes", {"query": 123})
assert "str" in str(exc.value).lower()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("create_entities", {"entities": "not an array"})
await server.handle_call_tool("create_entities", {"entities": "not an array"})
assert "array" in str(exc.value).lower() or "list" in str(exc.value).lower()
async def test_invalid_nested_fields(self, test_config):
async def test_invalid_nested_fields(self):
"""Test validation of nested object fields."""
server_instance = MemoryServer(config=test_config)
server = MemoryServer()
with pytest.raises(McpError) as exc:
await server_instance.handle_call_tool("create_entities", {
await server.handle_call_tool("create_entities", {
"entities": [{
"name": "Test",
# Missing required entity_type
"observations": []
}]
})
assert "entity_type" in str(exc.value).lower()
assert "entity_type" in str(exc.value).lower()