diff --git a/src/opencortex/app.py b/src/opencortex/app.py index 5462d32..e732e9d 100644 --- a/src/opencortex/app.py +++ b/src/opencortex/app.py @@ -13,6 +13,7 @@ get_collection_name, ) from opencortex.core.middlewares import WriteRequestContextMiddleware +from opencortex.mcp.routes import router as mcp_router from opencortex.runtime import AppRuntime, AppRuntimeConfig from opencortex.settings import Settings, get_settings from opencortex.storage.namespace import CortexNamespace @@ -91,6 +92,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: if settings.identity_context_enabled: app.add_middleware(WriteRequestContextMiddleware) app.include_router(store_router) + app.include_router(mcp_router) app.state.settings = settings return app diff --git a/src/opencortex/mcp/__init__.py b/src/opencortex/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/opencortex/mcp/routes.py b/src/opencortex/mcp/routes.py new file mode 100644 index 0000000..2fcf1ea --- /dev/null +++ b/src/opencortex/mcp/routes.py @@ -0,0 +1,251 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Streamable HTTP MCP routes for OpenCortex.""" + +from __future__ import annotations + +from contextlib import suppress +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response +from pydantic import ValidationError +from starlette import status +from starlette.requests import ClientDisconnect + +from opencortex.mcp.schemas import ( + MCP_PROTOCOL_VERSION, + JsonRpcError, + JsonRpcErrorCode, + JsonRpcRequest, + JsonRpcResponse, + McpMethod, + ToolCallParams, +) +from opencortex.mcp.tools import McpToolbox, list_tools +from opencortex.store.dependencies import ( + get_memory_forgetter, + get_memory_retriever, + get_memory_store, + get_resource_store, + get_session_ender, + get_session_store, +) +from opencortex.store.forget import MemoryForgetter +from opencortex.store.session.ender import SessionEnder +from opencortex.store.session.store import SessionStore +from opencortex.store.store import MemoryStore, ResourceStore +from opencortex.vector.retrieval import MemoryRetriever + +router = APIRouter() + + +@router.get("/mcp") +async def mcp_get() -> Response: + """Reject Streamable HTTP SSE sessions until streaming is implemented.""" + return Response(status_code=status.HTTP_405_METHOD_NOT_ALLOWED) + + +@router.delete("/mcp") +async def mcp_delete() -> Response: + """Acknowledge session termination for stateless MCP operation.""" + return Response(status_code=status.HTTP_405_METHOD_NOT_ALLOWED) + + +@router.post("/mcp", response_model=None) +async def mcp_post( + request: Request, + _transport: Annotated[None, Depends(validate_mcp_transport)], + toolbox: Annotated[McpToolbox, Depends(get_mcp_toolbox)], +) -> Response | dict[str, Any] | list[dict[str, Any]]: + """Handle MCP JSON-RPC messages over Streamable HTTP.""" + try: + payload = await request.json() + except (ClientDisconnect, ValueError) as exc: + return json_rpc_error_response( + request_id=None, + code=JsonRpcErrorCode.PARSE_ERROR, + message="Invalid JSON body", + data={"error": str(exc)}, + ).model_dump(mode="json", exclude_none=True, by_alias=True) + + if isinstance(payload, list): + if not payload: + return json_rpc_error_response( + request_id=None, + code=JsonRpcErrorCode.INVALID_REQUEST, + message="Batch request must not be empty", + ).model_dump(mode="json", exclude_none=True, by_alias=True) + responses = [ + response.model_dump(mode="json", exclude_none=True, by_alias=True) + for item in payload + if (response := await handle_json_rpc_message(item, toolbox)) is not None + ] + if not responses: + return Response(status_code=status.HTTP_202_ACCEPTED) + return responses + + response = await handle_json_rpc_message(payload, toolbox) + if response is None: + return Response(status_code=status.HTTP_202_ACCEPTED) + return response.model_dump(mode="json", exclude_none=True, by_alias=True) + + +def get_mcp_toolbox( + retriever: Annotated[MemoryRetriever, Depends(get_memory_retriever)], + memory_store: Annotated[MemoryStore, Depends(get_memory_store)], + resource_store: Annotated[ResourceStore, Depends(get_resource_store)], + forgetter: Annotated[MemoryForgetter, Depends(get_memory_forgetter)], + session_store: Annotated[SessionStore, Depends(get_session_store)], + session_ender: Annotated[SessionEnder, Depends(get_session_ender)], +) -> McpToolbox: + """Return MCP tool dispatcher backed by app runtime dependencies.""" + return McpToolbox( + retriever=retriever, + memory_store=memory_store, + resource_store=resource_store, + forgetter=forgetter, + session_store=session_store, + session_ender=session_ender, + ) + + +def validate_mcp_transport( + accept: Annotated[str, Header()] = "", + content_type: Annotated[str, Header()] = "", +) -> None: + """Validate Streamable HTTP transport headers before runtime dependencies.""" + validate_streamable_http_headers(accept=accept, content_type=content_type) + + +async def handle_json_rpc_message( + payload: Any, + toolbox: McpToolbox, +) -> JsonRpcResponse | None: + """Handle one JSON-RPC request or notification.""" + try: + rpc_request = JsonRpcRequest.model_validate(payload) + except ValidationError as exc: + return json_rpc_error_response( + request_id=payload.get("id") if isinstance(payload, dict) else None, + code=JsonRpcErrorCode.INVALID_REQUEST, + message="Invalid JSON-RPC request", + data={"errors": exc.errors()}, + ) + + if rpc_request.id is None: + with suppress(McpJsonRpcError): + await handle_notification(rpc_request) + return None + + try: + result = await dispatch_request(rpc_request, toolbox) + except McpJsonRpcError as exc: + return json_rpc_error_response( + request_id=rpc_request.id, + code=exc.code, + message=exc.message, + ) + except ValidationError as exc: + return json_rpc_error_response( + request_id=rpc_request.id, + code=JsonRpcErrorCode.INVALID_PARAMS, + message="Invalid method params", + data={"errors": exc.errors()}, + ) + except ValueError as exc: + return json_rpc_error_response( + request_id=rpc_request.id, + code=JsonRpcErrorCode.INVALID_PARAMS, + message=str(exc), + ) + return JsonRpcResponse(id=rpc_request.id, result=result) + + +async def handle_notification(rpc_request: JsonRpcRequest) -> None: + """Handle supported JSON-RPC notifications.""" + if rpc_request.method == McpMethod.INITIALIZED: + return + if rpc_request.method.startswith("notifications/"): + return + raise ValueError(f"Unsupported JSON-RPC notification: {rpc_request.method}") + + +async def dispatch_request( + rpc_request: JsonRpcRequest, + toolbox: McpToolbox, +) -> dict[str, Any]: + """Dispatch one request-bearing MCP method.""" + method = rpc_request.method + params = rpc_request.params or {} + if method == McpMethod.INITIALIZE: + return initialize_result() + if method == McpMethod.PING: + return {} + if method == McpMethod.TOOLS_LIST: + return { + "tools": [ + tool.model_dump(mode="json", by_alias=True) for tool in list_tools() + ] + } + if method == McpMethod.TOOLS_CALL: + tool_call = ToolCallParams.model_validate(params) + result = await toolbox.call(tool_call.name, tool_call.arguments) + return result.model_dump(mode="json", by_alias=True) + raise McpJsonRpcError( + code=JsonRpcErrorCode.METHOD_NOT_FOUND, + message=f"Method not found: {method}", + ) + + +def initialize_result() -> dict[str, Any]: + """Return MCP initialize capabilities.""" + return { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "opencortex", "version": "0.8.0"}, + } + + +def validate_streamable_http_headers(accept: str, content_type: str) -> None: + """Validate Streamable HTTP POST headers.""" + if "application/json" not in content_type.lower(): + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="MCP POST requires Content-Type: application/json", + ) + accept_header = accept.lower() + if "application/json" not in accept_header or "text/event-stream" not in ( + accept_header + ): + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail=( + "MCP POST requires Accept containing application/json and " + "text/event-stream" + ), + ) + + +def json_rpc_error_response( + request_id: str | int | None, + code: JsonRpcErrorCode, + message: str, + data: dict[str, Any] | None = None, +) -> JsonRpcResponse: + """Return a JSON-RPC error response.""" + return JsonRpcResponse( + id=request_id, + error=JsonRpcError(code=code.integer, message=message, data=data), + ) + + +class McpJsonRpcError(Exception): + """Exception carrying a JSON-RPC error code.""" + + def __init__(self, code: JsonRpcErrorCode, message: str) -> None: + """Initialize a JSON-RPC exception.""" + super().__init__(message) + self.code = code + self.message = message + + +__all__ = ["router"] diff --git a/src/opencortex/mcp/schemas.py b/src/opencortex/mcp/schemas.py new file mode 100644 index 0000000..0f88e44 --- /dev/null +++ b/src/opencortex/mcp/schemas.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Schemas for OpenCortex MCP Streamable HTTP endpoints.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +MCP_PROTOCOL_VERSION = "2025-06-18" + + +class McpMethod(StrEnum): + """JSON-RPC methods implemented by the OpenCortex MCP endpoint.""" + + INITIALIZE = "initialize" + INITIALIZED = "notifications/initialized" + PING = "ping" + TOOLS_LIST = "tools/list" + TOOLS_CALL = "tools/call" + + +class JsonRpcErrorCode(StrEnum): + """JSON-RPC error codes used by the MCP endpoint.""" + + PARSE_ERROR = "-32700" + INVALID_REQUEST = "-32600" + METHOD_NOT_FOUND = "-32601" + INVALID_PARAMS = "-32602" + INTERNAL_ERROR = "-32603" + + @property + def integer(self) -> int: + """Return the JSON-RPC integer value.""" + return int(self.value) + + +class JsonRpcRequest(BaseModel): + """One JSON-RPC request or notification.""" + + jsonrpc: Literal["2.0"] = "2.0" + id: str | int | None = None + method: str + params: dict[str, Any] | None = None + + model_config = ConfigDict(extra="forbid") + + +class JsonRpcError(BaseModel): + """JSON-RPC error object.""" + + code: int + message: str + data: dict[str, Any] | None = None + + +class JsonRpcResponse(BaseModel): + """JSON-RPC response object.""" + + jsonrpc: Literal["2.0"] = "2.0" + id: str | int | None = None + result: Any | None = None + error: JsonRpcError | None = None + + @model_validator(mode="after") + def validate_result_or_error(self) -> "JsonRpcResponse": + """Require exactly one response payload shape.""" + if (self.result is None) == (self.error is None): + raise ValueError("JSON-RPC response requires result or error") + return self + + +class McpTool(BaseModel): + """MCP tool descriptor.""" + + name: str + title: str + description: str + input_schema: dict[str, Any] = Field( + default_factory=dict, + serialization_alias="inputSchema", + validation_alias="inputSchema", + ) + + +class ToolCallParams(BaseModel): + """Params for the MCP tools/call method.""" + + name: str + arguments: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +class ToolContent(BaseModel): + """One MCP tool result content item.""" + + type: Literal["text"] = "text" + text: str + + +class ToolResult(BaseModel): + """MCP tool call result.""" + + content: list[ToolContent] + structured_content: dict[str, Any] = Field( + default_factory=dict, + serialization_alias="structuredContent", + validation_alias="structuredContent", + ) + is_error: bool = Field( + default=False, + serialization_alias="isError", + validation_alias="isError", + ) + + +__all__ = [ + "MCP_PROTOCOL_VERSION", + "JsonRpcError", + "JsonRpcErrorCode", + "JsonRpcRequest", + "JsonRpcResponse", + "McpMethod", + "McpTool", + "ToolCallParams", + "ToolContent", + "ToolResult", +] diff --git a/src/opencortex/mcp/tools.py b/src/opencortex/mcp/tools.py new file mode 100644 index 0000000..3c7aa6b --- /dev/null +++ b/src/opencortex/mcp/tools.py @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: Apache-2.0 +"""MCP tool definitions backed by OpenCortex memory flows.""" + +from __future__ import annotations + +import json +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from opencortex.core.identity import get_identity_profile +from opencortex.mcp.schemas import McpTool, ToolContent, ToolResult +from opencortex.store.forget import MemoryForgetter +from opencortex.store.schemas import ( + MemoryForgetRequest, + SessionEndRequest, + SessionTurnRequest, + StoreRequest, + memory_store_input_from_request, + resource_store_input_from_request, + session_end_input_from_request, + session_message_input_from_request, +) +from opencortex.store.session.ender import SessionEnder +from opencortex.store.session.store import SessionStore +from opencortex.store.store import MemoryStore, ResourceStore +from opencortex.store.types import StoreRecordType +from opencortex.vector.retrieval import MemoryRetriever, RetrievalRequest + + +class McpToolName(StrEnum): + """OpenCortex tools exposed through MCP.""" + + SEARCH = "opencortex.search" + STORE_MEMORY = "opencortex.store_memory" + STORE_RESOURCE = "opencortex.store_resource" + FORGET = "opencortex.forget" + SESSION_MESSAGE = "opencortex.session_message" + SESSION_END = "opencortex.session_end" + + +class StoreMemoryToolInput(BaseModel): + """Input schema for storing one memory through MCP.""" + + content: str = Field(..., min_length=1) + category: str = Field(default="semantic") + metadata: dict[str, Any] = Field(default_factory=dict) + source: dict[str, Any] = Field(default_factory=lambda: {"kind": "api"}) + + model_config = ConfigDict(extra="forbid") + + +class StoreResourceToolInput(StoreMemoryToolInput): + """Input schema for storing one resource through MCP.""" + + +class McpToolbox(BaseModel): + """Runtime dependencies used by MCP tool dispatch.""" + + retriever: MemoryRetriever + memory_store: MemoryStore + resource_store: ResourceStore + forgetter: MemoryForgetter + session_store: SessionStore + session_ender: SessionEnder + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def call(self, name: str, arguments: dict[str, Any]) -> ToolResult: + """Call one OpenCortex MCP tool.""" + tool_name = McpToolName(name) + if tool_name == McpToolName.SEARCH: + return await self._search(arguments) + if tool_name == McpToolName.STORE_MEMORY: + return await self._store_memory(arguments) + if tool_name == McpToolName.STORE_RESOURCE: + return await self._store_resource(arguments) + if tool_name == McpToolName.FORGET: + return await self._forget(arguments) + if tool_name == McpToolName.SESSION_MESSAGE: + return await self._session_message(arguments) + if tool_name == McpToolName.SESSION_END: + return await self._session_end(arguments) + raise ValueError(f"Unsupported MCP tool: {name}") + + async def _search(self, arguments: dict[str, Any]) -> ToolResult: + request = RetrievalRequest.model_validate(arguments) + result = await self.retriever.search(request, profile=get_identity_profile()) + return tool_result(result.model_dump(mode="json")) + + async def _store_memory(self, arguments: dict[str, Any]) -> ToolResult: + tool_input = StoreMemoryToolInput.model_validate(arguments) + request = StoreRequest.model_validate( + { + **tool_input.model_dump(mode="json"), + "type": StoreRecordType.MEMORY, + } + ) + stored = await self.memory_store.store(memory_store_input_from_request(request)) + return tool_result(stored.model_dump(mode="json")) + + async def _store_resource(self, arguments: dict[str, Any]) -> ToolResult: + tool_input = StoreResourceToolInput.model_validate(arguments) + request = StoreRequest.model_validate( + { + **tool_input.model_dump(mode="json"), + "type": StoreRecordType.RESOURCE, + } + ) + stored = await self.resource_store.store( + resource_store_input_from_request(request) + ) + return tool_result(stored.model_dump(mode="json")) + + async def _forget(self, arguments: dict[str, Any]) -> ToolResult: + request = MemoryForgetRequest.model_validate(arguments) + result = await self.forgetter.forget(request, profile=get_identity_profile()) + return tool_result(result.model_dump(mode="json")) + + async def _session_message(self, arguments: dict[str, Any]) -> ToolResult: + request = SessionTurnRequest.model_validate(arguments) + result = await self.session_store.message( + session_message_input_from_request(request) + ) + return tool_result(result.model_dump(mode="json")) + + async def _session_end(self, arguments: dict[str, Any]) -> ToolResult: + request = SessionEndRequest.model_validate(arguments) + result = await self.session_ender.end(session_end_input_from_request(request)) + return tool_result(result.model_dump(mode="json")) + + +def list_tools() -> list[McpTool]: + """Return MCP tool descriptors.""" + return [ + McpTool( + name=McpToolName.SEARCH, + title="Search OpenCortex Memory", + description=( + "Recall memory and resource records using OpenCortex retrieval." + ), + inputSchema=RetrievalRequest.model_json_schema(), + ), + McpTool( + name=McpToolName.STORE_MEMORY, + title="Store OpenCortex Memory", + description="Store one semantic, episodic, or procedural memory record.", + inputSchema=StoreMemoryToolInput.model_json_schema(), + ), + McpTool( + name=McpToolName.STORE_RESOURCE, + title="Store OpenCortex Resource", + description="Store one resource document and enqueue its side indexes.", + inputSchema=StoreResourceToolInput.model_json_schema(), + ), + McpTool( + name=McpToolName.FORGET, + title="Forget OpenCortex Memory", + description="Forget the top semantic match or an explicit OpenCortex URI.", + inputSchema=MemoryForgetRequest.model_json_schema(), + ), + McpTool( + name=McpToolName.SESSION_MESSAGE, + title="Store OpenCortex Session Message", + description="Store one conversation turn and enqueue session side effects.", + inputSchema=SessionTurnRequest.model_json_schema(), + ), + McpTool( + name=McpToolName.SESSION_END, + title="End OpenCortex Session", + description="Close a conversation session and write its final memory tree.", + inputSchema=SessionEndRequest.model_json_schema(), + ), + ] + + +def tool_result(data: dict[str, Any]) -> ToolResult: + """Return an MCP-compatible structured result.""" + return ToolResult( + content=[ + ToolContent( + text=json.dumps(data, ensure_ascii=False, separators=(",", ":")), + ) + ], + structuredContent=data, + ) + + +__all__ = ["McpToolName", "McpToolbox", "list_tools"] diff --git a/tests/opencortex/test_app.py b/tests/opencortex/test_app.py index 16f5bad..5538a5f 100644 --- a/tests/opencortex/test_app.py +++ b/tests/opencortex/test_app.py @@ -21,6 +21,10 @@ from opencortex.store.document_tree import DocumentParser captured_system_prompts: list[str] = [] +MCP_HEADERS = { + "content-type": "application/json", + "accept": "application/json, text/event-stream", +} def app_settings(data_root: str) -> Settings: @@ -222,6 +226,177 @@ async def test_create_app_exposes_write_and_search_routes(self) -> None: self.assertIn("/api/v1/memory/forget", paths) self.assertIn("/api/v1/session/message", paths) self.assertIn("/api/v1/session/end", paths) + self.assertIn("/mcp", paths) + + async def test_mcp_get_returns_405(self) -> None: + """Streamable HTTP GET is rejected until SSE sessions are supported.""" + app = create_app(settings=app_settings(":memory:")) + transport = ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + response = await client.get("/mcp") + + self.assertEqual(response.status_code, 405) + + async def test_mcp_rejects_missing_streamable_http_accept(self) -> None: + """MCP POST requires the Streamable HTTP Accept header pair.""" + app = create_app(settings=app_settings(":memory:")) + transport = ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + response = await client.post( + "/mcp", + headers={ + "content-type": "application/json", + "accept": "application/json", + }, + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + ) + + self.assertEqual(response.status_code, 406) + + async def test_mcp_initialize_and_tools_list(self) -> None: + """MCP exposes initialize and tools/list over Streamable HTTP.""" + with TemporaryDirectory() as data_root: + app = create_app(settings=app_settings(data_root)) + with ( + patch( + "opencortex.llm.client.LLMCompletion.complete", + new=AsyncMock(side_effect=fake_llm_completion), + ), + patch( + "opencortex.vector.embedder.OpenAIEmbeddingClient.embed", + side_effect=fake_embedding, + ), + patch( + "opencortex.vector.embedder.OpenAIEmbeddingClient.embed_batch", + side_effect=fake_embedding_batch, + ), + ): + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + init_response = await client.post( + "/mcp", + headers=MCP_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, + }, + ) + tools_response = await client.post( + "/mcp", + headers=MCP_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "tools-1", + "method": "tools/list", + }, + ) + + self.assertEqual(init_response.status_code, 200) + init_result = init_response.json()["result"] + self.assertEqual(init_result["protocolVersion"], "2025-06-18") + self.assertIn("tools", init_result["capabilities"]) + + self.assertEqual(tools_response.status_code, 200) + tool_names = {tool["name"] for tool in tools_response.json()["result"]["tools"]} + self.assertIn("opencortex.search", tool_names) + self.assertIn("opencortex.store_memory", tool_names) + self.assertIn("opencortex.store_resource", tool_names) + search_tool = next( + tool + for tool in tools_response.json()["result"]["tools"] + if tool["name"] == "opencortex.search" + ) + self.assertIn("inputSchema", search_tool) + + async def test_mcp_store_and_search_tools_use_new_chain(self) -> None: + """MCP tools/call writes and recalls through the current memory chain.""" + with TemporaryDirectory() as data_root: + app = create_app(settings=app_settings(data_root)) + with ( + patch( + "opencortex.llm.client.LLMCompletion.complete", + new=AsyncMock(side_effect=fake_llm_completion), + ), + patch( + "opencortex.vector.embedder.OpenAIEmbeddingClient.embed", + side_effect=keyword_embedding, + ), + patch( + "opencortex.vector.embedder.OpenAIEmbeddingClient.embed_batch", + side_effect=keyword_embedding_batch, + ), + ): + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + store_response = await client.post( + "/mcp", + headers=MCP_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "store-1", + "method": "tools/call", + "params": { + "name": "opencortex.store_memory", + "arguments": { + "content": ( + "Zephyr uses a blue notebook for planning." + ), + "category": "semantic", + "metadata": {"entities": ["Zephyr"]}, + "source": {"kind": "manual"}, + }, + }, + }, + ) + await app.state.store_event_worker.wait_idle() + search_response = await client.post( + "/mcp", + headers=MCP_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "search-1", + "method": "tools/call", + "params": { + "name": "opencortex.search", + "arguments": { + "query": "Zephyr notebook planning", + "limit": 3, + }, + }, + }, + ) + + self.assertEqual(store_response.status_code, 200) + store_result = store_response.json()["result"] + self.assertFalse(store_result["isError"]) + stored_uri = store_result["structuredContent"]["uri"] + self.assertEqual(store_result["structuredContent"]["context_type"], "memory") + + self.assertEqual(search_response.status_code, 200) + search_result = search_response.json()["result"] + self.assertFalse(search_result["isError"]) + results = search_result["structuredContent"]["results"] + self.assertTrue(any(result["uri"] == stored_uri for result in results)) async def test_lifespan_initializes_store_dependencies(self) -> None: """Lifespan initializes state used by dependency injection."""