diff --git a/pyproject.toml b/pyproject.toml index 9b87bd77..cd674bff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,8 @@ dependencies = [ "sentence-transformers>=2.2.0", # MCP server - "mcp>=1.0.0,<2.0.0", + "mcp>=2.0.0,<3.0.0", + "jsonschema>=4.20.0", # MCP v2 low-level handlers require explicit schema validation # CLI "typer>=0.9.0", diff --git a/scripts/smoke/repogolem_mcp_transport_smoke.py b/scripts/smoke/repogolem_mcp_transport_smoke.py index 230ed920..618737f0 100755 --- a/scripts/smoke/repogolem_mcp_transport_smoke.py +++ b/scripts/smoke/repogolem_mcp_transport_smoke.py @@ -114,7 +114,7 @@ async def smoke_context(name: str, *, cwd: Path, timeout: float, live_store: boo def _raise_if_tool_error(result, tool_name: str) -> None: - if getattr(result, "isError", False): + if getattr(result, "is_error", False): raise RuntimeError(f"{tool_name} returned isError=true: {_compact_tool_text(result)}") text = _compact_tool_text(result).lower() if "transport closed" in text or "connection closed" in text: diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 3a14c2bd..4908253f 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -192,7 +192,7 @@ async def _search(self, arguments: dict[str, Any]) -> tuple[str, dict[str, Any] content, structured = result return self._content_text(content), structured if isinstance(structured, dict) else None, False if hasattr(result, "content"): - return self._content_text(result.content), None, bool(getattr(result, "isError", False)) + return self._content_text(result.content), None, bool(getattr(result, "is_error", False)) return self._content_text(result), None, False @staticmethod diff --git a/src/brainlayer/mcp/__init__.py b/src/brainlayer/mcp/__init__.py index 342e6b4d..b6ee4b05 100644 --- a/src/brainlayer/mcp/__init__.py +++ b/src/brainlayer/mcp/__init__.py @@ -7,14 +7,20 @@ from copy import deepcopy from typing import Any +import jsonschema + logger = logging.getLogger(__name__) from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolRequestParams, CallToolResult, + CompleteRequestParams, CompleteResult, Completion, + ListToolsResult, + PaginatedRequestParams, TextContent, Tool, ToolAnnotations, @@ -92,11 +98,63 @@ async def _with_timeout(coro, timeout: float | None = None): ), ) ], - isError=True, + is_error=True, ) -# Create MCP server +async def _handle_list_tools_request(_ctx: Any, _params: PaginatedRequestParams | None) -> ListToolsResult: + """Adapt BrainLayer's tool palette to the MCP v2 low-level handler contract.""" + return ListToolsResult(tools=await list_tools()) + + +async def _handle_completion_request(_ctx: Any, params: CompleteRequestParams) -> CompleteResult: + """Adapt typed MCP v2 completion params to the existing completion logic.""" + return await handle_completion(params.ref, params.argument) + + +def _normalize_call_tool_result(result: Any) -> CallToolResult: + """Preserve the result forms accepted by the MCP v1 call-tool decorator.""" + if isinstance(result, CallToolResult): + return result + if isinstance(result, tuple) and len(result) == 2: + content, structured_content = result + elif isinstance(result, dict): + content = [TextContent(type="text", text=json.dumps(result, indent=2))] + structured_content = result + elif hasattr(result, "__iter__"): + content = result + structured_content = None + else: + raise TypeError(f"Unexpected return type from tool: {type(result).__name__}") + return CallToolResult(content=list(content), structured_content=structured_content, is_error=False) + + +async def _handle_call_tool_request(_ctx: Any, params: CallToolRequestParams) -> CallToolResult: + """Validate and route a typed MCP v2 tool call while preserving v1 behavior.""" + arguments = params.arguments or {} + try: + tool = next((candidate for candidate in await list_tools() if candidate.name == params.name), None) + if tool is not None: + try: + jsonschema.validate(instance=arguments, schema=tool.input_schema) + except jsonschema.ValidationError as exc: + return _error_result(f"Input validation error: {exc.message}") + + result = _normalize_call_tool_result(await call_tool(params.name, arguments)) + + if tool is not None and tool.output_schema is not None and not result.is_error: + if result.structured_content is None: + return _error_result("Output validation error: output_schema defined but no structured output returned") + try: + jsonschema.validate(instance=result.structured_content, schema=tool.output_schema) + except jsonschema.ValidationError as exc: + return _error_result(f"Output validation error: {exc.message}") + return result + except Exception as exc: + return _error_result(str(exc)) + + +# Create MCP server using the MCP v2 low-level handler API. server = Server( "brainlayer", instructions=( @@ -115,46 +173,48 @@ async def _with_timeout(coro, timeout: float | None = None): "brain_update/brain_expand/brain_tags are deprecated — see their descriptions for alternatives.\n" 'Project scoping: auto-inferred from cwd. Override with project="all".' ), + on_list_tools=_handle_list_tools_request, + on_call_tool=_handle_call_tool_request, + on_completion=_handle_completion_request, ) # Tool annotations _MAX_FULL_CONTENT_RESULT_CHARS = 250_000 +_MAX_RESULT_META = {"anthropic/maxResultSizeChars": _MAX_FULL_CONTENT_RESULT_CHARS} _READ_ONLY = ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - **{"anthropic/maxResultSizeChars": _MAX_FULL_CONTENT_RESULT_CHARS}, + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ) _RECALL_READ_ONLY = ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - **{"anthropic/maxResultSizeChars": _MAX_FULL_CONTENT_RESULT_CHARS}, + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ) _WRITE = ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ) _WRITE_IDEMPOTENT = ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ) _DESTRUCTIVE = ToolAnnotations( - readOnlyHint=False, - destructiveHint=True, - idempotentHint=False, - openWorldHint=False, + read_only_hint=False, + destructive_hint=True, + idempotent_hint=False, + open_world_hint=False, ) _DEFAULT_STRING_MAX_LENGTH = 256 @@ -406,7 +466,8 @@ def _full_tool_definitions() -> list[Tool]: title="Search Knowledge Base", description="""Search BrainLayer's persistent memory for past decisions, project history, debugging notes, preferences, and other stored knowledge. Use when: the user asks what was decided before, how something was implemented, what happened to a file, or what you are working on. Don't use when: you need current session context or stats (use brain_recall), a named entity graph lookup (use brain_entity), or to save new information (use brain_store). query should be a natural-language lookup phrase; file_path switches to file-history routing, chunk_id expands a known result, and project narrows scope. num_results defaults to 5 and detail defaults to 'compact'; add date, tag, intent, or source filters only when they materially narrow the search. Returns ranked matches with scores, metadata, and compact snippets or full content; after finding a promising chunk, call brain_search with chunk_id or use brain_recall for session-level context.""", annotations=_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -588,7 +649,8 @@ def _full_tool_definitions() -> list[Tool]: title="Resume From PreCompact Checkpoint", description="""Return recent PreCompact checkpoint chunks for session recovery. Use when: an agent asks what it was working on after compaction or needs explicit session-restore state. Don't use for normal topical search; brain_search excludes checkpoints by default to avoid checkpoint pollution. session_id narrows to one session when known, and lookback_days defaults to 7.""", annotations=_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -613,7 +675,7 @@ def _full_tool_definitions() -> list[Tool]: title="Store Memory", description="""Save decisions, learnings, corrections, issues, and other durable memories so future sessions can retrieve the reasoning with brain_search. Use when: you made a decision, learned something important, hit a bug worth tracking, or received a correction that should persist. Don't use when: you are retrieving existing knowledge (use brain_search), deeply extracting entities from large text with richer indexing (use brain_digest), or archiving/superseding old chunks without writing new content (use brain_archive or brain_supersede). content should explain what happened and why; type auto-detects if omitted, and project, tags, and importance improve retrieval. For decisions, add confidence_score, outcome, reversibility, and files_changed; for issues, add status, severity, file_path, function_name, and line_number. Returns a new chunk_id plus related similar memories, and supersedes can replace an older chunk in the same write.""", annotations=_WRITE, - inputSchema=_bounded_input_schema( + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -710,14 +772,15 @@ def _full_tool_definitions() -> list[Tool]: "required": ["content"], } ), - outputSchema=_STORE_OUTPUT_SCHEMA, + output_schema=_STORE_OUTPUT_SCHEMA, ), Tool( name="brain_get_person", title="Get Person Context", description="""Get a person's profile, graph relations, and linked memories in one call. Use when: preparing for a meeting, recalling someone's preferences or constraints, or gathering person-specific context before taking action. Don't use when: you need fuzzy topic search across all memories (use brain_search), generic entity lookup without scoped memories (use brain_entity), or you are storing new information about the person (use brain_store). name should be the best-known person name; context reranks memories for the current task, and num_memories defaults to 10. Returns profile fields, related entities, and relevant memory chunks; after identifying the right person, use brain_search for broader topic recall if needed.""", annotations=_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -746,7 +809,8 @@ def _full_tool_definitions() -> list[Tool]: title="Recall / Search / Entity Lookup", description="""Get working context, recent sessions, plan/session links, per-session operations, summaries, stats, or routed search from one entry point. Use when: you need 'what am I working on', recent session history, plan linkage, operation groups for a session, or knowledge-base health stats. Don't use when: you already know you want topical memory search (use brain_search), a direct entity graph lookup (use brain_entity), or to store or digest new content (use brain_store or brain_digest). mode can be explicit or auto-detected from query; session_id is required for operations and summary, plan_name targets plan mode, and hours, days, and limit control context windows. In search mode, file_path, chunk_id, content filters, num_results, and detail='compact'|'full' behave like brain_search. Returns structured context, search results, or stats depending on mode; use brain_search after broad routing when you need tighter topical retrieval.""", annotations=_RECALL_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -929,7 +993,7 @@ def _full_tool_definitions() -> list[Tool]: title="Digest Content", description="""Digest large text content into searchable memory plus extracted entities and relations in the knowledge graph. Use when: processing research notes, audits, transcripts, or other large text that should be deeply indexed instead of stored as a quick note. Don't use when: a short decision or learning can be saved directly with brain_store, you only need to retrieve knowledge with brain_search, or you want a specific entity lookup with brain_entity. mode='digest' writes a new enriched chunk, mode='connect' compares content to existing knowledge and returns a proposed connection or supersede plan without storing, and mode='enrich' backfills existing chunks using limit. content should be the raw text; title, project, and participants improve extraction quality. Returns extracted entity and relation counts or a proposal, and you can inspect the indexed result later with brain_search or brain_entity.""", annotations=_WRITE, - inputSchema=_bounded_input_schema( + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -972,7 +1036,8 @@ def _full_tool_definitions() -> list[Tool]: title="Entity Lookup", description="""Look up a known entity and traverse its relationships in the knowledge graph. Use when: the user names a specific person, project, company, library, tool, or technology and you need structured connections rather than fuzzy search. Don't use when: you need broad topical recall across memories (use brain_search), person-specific profile plus memories in one call (use brain_get_person), or to save new facts (use brain_store or brain_digest). query should be the likely entity name; action defaults to 'lookup', while action='list' browses an entity_type with limit and offset pagination. Returns entity records plus connected relations, and after finding the right entity you can use brain_search to pull narrative memory around it.""", annotations=_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1042,7 +1107,8 @@ def _full_tool_definitions() -> list[Tool]: title="Expand Chunk Context", description="Deprecated. Use brain_search with detail='full' to get full chunk content, or brain_recall with conversation_id to get session context.", annotations=_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1067,7 +1133,7 @@ def _full_tool_definitions() -> list[Tool]: title="Update or Archive Memory", description="Deprecated. Use brain_store with supersedes param to replace a memory, or brain_archive/brain_supersede for lifecycle management.", annotations=_WRITE_IDEMPOTENT, - inputSchema=_bounded_input_schema( + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1110,7 +1176,8 @@ def _full_tool_definitions() -> list[Tool]: title="Tag Discovery", description="Deprecated. Use brain_recall(mode='search', tag='prefix') to find tagged memories, or brain_store(tags=[...]) to tag when storing.", annotations=_READ_ONLY, - inputSchema=_bounded_input_schema( + meta=_MAX_RESULT_META, + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1148,7 +1215,7 @@ def _full_tool_definitions() -> list[Tool]: title="Supersede Memory", description="""Mark an old chunk as replaced by a newer one and remove the old chunk from default search while keeping history. Use when: a technical fact, decision, or learning has been updated and search should prefer the replacement. Don't use when: you are writing the new memory itself (use brain_store with supersedes if you are creating it now) or you only need to hide a stale chunk without a replacement (use brain_archive). old_chunk_id and new_chunk_id are required; safety_check defaults to 'auto' for technical content, while personal data requires safety_check='confirm' and confirm=True. Returns the action taken, and you can verify the surviving record with brain_search.""", annotations=_DESTRUCTIVE, - inputSchema=_bounded_input_schema( + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1181,7 +1248,7 @@ def _full_tool_definitions() -> list[Tool]: title="Archive Memory", description="""Archive a chunk with a soft-delete timestamp so it disappears from default search but remains recoverable. Use when: a memory is stale, irrelevant, or duplicative and should stop surfacing without being permanently deleted. Don't use when: a newer chunk should explicitly replace it (use brain_supersede) or you are writing fresh information (use brain_store). chunk_id is required and reason is optional audit metadata. Returns the archived chunk_id, and you can use brain_search or direct chunk lookup later if you need the history.""", annotations=_DESTRUCTIVE, - inputSchema=_bounded_input_schema( + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1203,7 +1270,7 @@ def _full_tool_definitions() -> list[Tool]: title="Enrich Chunks", description="""Run enrichment on existing chunks to backfill entities, summaries, and related metadata without rewriting the original memory text. Use when: you want realtime enrichment for recent writes, cheaper batch processing for backlog, local offline enrichment, or progress stats for the enrichment system. Don't use when: you are ingesting brand-new long-form content (use brain_digest), saving a quick note (use brain_store), or retrieving knowledge (use brain_search). mode defaults to 'realtime'; batch uses phase='submit'|'poll'|'import'|'run', stats=True returns progress only, and limit, since_hours, or chunk_ids narrow scope. Returns enrichment progress or per-run results, and you can inspect enriched chunks afterward with brain_search or brain_entity.""", annotations=_WRITE, - inputSchema=_bounded_input_schema( + input_schema=_bounded_input_schema( { "type": "object", "properties": { @@ -1256,7 +1323,6 @@ def _full_tool_definitions() -> list[Tool]: _FULL_TOOL_NAMES = tuple(tool.name for tool in _full_tool_definitions()) -@server.list_tools() async def list_tools() -> list[Tool]: """List tools exposed by this server session's palette.""" return _tool_palette.expose(_full_tool_definitions()) @@ -1265,7 +1331,6 @@ async def list_tools() -> list[Tool]: # --- Completions --- -@server.completion() async def handle_completion(ref, argument) -> CompleteResult: """Provide completions for tool arguments.""" if not hasattr(ref, "name"): @@ -1286,7 +1351,7 @@ async def handle_completion(ref, argument) -> CompleteResult: normalized.append(norm) if arg_value: normalized = [p for p in normalized if p.lower().startswith(arg_value.lower())] - return CompleteResult(completion=Completion(values=sorted(normalized)[:20], hasMore=len(normalized) > 20)) + return CompleteResult(completion=Completion(values=sorted(normalized)[:20], has_more=len(normalized) > 20)) except Exception: return CompleteResult(completion=Completion(values=[])) @@ -1326,7 +1391,6 @@ async def handle_completion(ref, argument) -> CompleteResult: # --- Tool routing --- -@server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]): """Handle tool calls — 3 primary tools + backward-compat aliases.""" @@ -1336,7 +1400,7 @@ async def call_tool(name: str, arguments: dict[str, Any]): receipt = _tool_palette.expand(_FULL_TOOL_NAMES) return CallToolResult( content=[TextContent(type="text", text=json.dumps(receipt, sort_keys=True))], - structuredContent=receipt, + structured_content=receipt, ) if name in _FULL_TOOL_NAMES and not _tool_palette.is_exposed(name): @@ -1501,7 +1565,7 @@ async def call_tool(name: str, arguments: dict[str, Any]): ), ) ], - isError=True, + is_error=True, ) elif name == "brain_entity": @@ -1521,7 +1585,7 @@ async def call_tool(name: str, arguments: dict[str, Any]): if not query: return CallToolResult( content=[TextContent(type="text", text="query is required for lookup action.")], - isError=True, + is_error=True, ) return await _with_timeout( _brain_recall( @@ -1545,7 +1609,7 @@ async def call_tool(name: str, arguments: dict[str, Any]): ), ) ], - isError=True, + is_error=True, ) elif name == "brain_tags": @@ -1561,7 +1625,7 @@ async def call_tool(name: str, arguments: dict[str, Any]): ), ) ], - isError=True, + is_error=True, ) elif name == "brain_enrich": diff --git a/src/brainlayer/mcp/_shared.py b/src/brainlayer/mcp/_shared.py index 84a1a441..c7137f1b 100644 --- a/src/brainlayer/mcp/_shared.py +++ b/src/brainlayer/mcp/_shared.py @@ -324,7 +324,7 @@ def _normalize_project_name(project: str | None) -> str | None: def _error_result(message: str): """Create an error CallToolResult.""" - return CallToolResult(content=[TextContent(type="text", text=message)], isError=True) + return CallToolResult(content=[TextContent(type="text", text=message)], is_error=True) def _memory_to_dict(item: dict) -> dict: diff --git a/src/brainlayer/mcp/palette.py b/src/brainlayer/mcp/palette.py index 88cc5f55..3ad094a8 100644 --- a/src/brainlayer/mcp/palette.py +++ b/src/brainlayer/mcp/palette.py @@ -47,7 +47,7 @@ def expose(self, full_tools: Sequence[Tool]) -> list[Tool]: Tool( name=EXPAND_TOOL_NAME, description="Expose all tools.", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ) return core_tools diff --git a/tests/mock_mcp/base.py b/tests/mock_mcp/base.py index ab98ad06..4cb7e1ec 100644 --- a/tests/mock_mcp/base.py +++ b/tests/mock_mcp/base.py @@ -14,10 +14,9 @@ from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Callable -from mcp.client.session import ClientSession +from mcp.client import Client from mcp.server import Server -from mcp.shared.memory import create_connected_server_and_client_session -from mcp.types import TextContent, Tool +from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent, Tool @dataclass @@ -33,8 +32,8 @@ class ToolCall: class MockMcpServer: """Base class for mock MCP servers with call logging. - Uses the low-level Server API with @server.list_tools() and - @server.call_tool() handlers. Subclasses register tools via + Uses the low-level Server API with ``on_list_tools`` and + ``on_call_tool`` handlers. Subclasses register tools via _register_tools() which populates _tools and _handlers dicts. Usage: @@ -46,39 +45,32 @@ class MockMcpServer: """ def __init__(self, name: str = "mock-server"): - self._server = Server(name) self._call_log: list[ToolCall] = [] self._tools: dict[str, Tool] = {} self._handlers: dict[str, Callable] = {} self._register_tools() - self._setup_server_handlers() + self._server = Server(name, on_list_tools=self._list_tools, on_call_tool=self._call_tool) - def _setup_server_handlers(self) -> None: - """Wire up the MCP list_tools and call_tool handlers.""" - mock_ref = self + async def _list_tools(self, _ctx: Any, _params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=list(self._tools.values())) - @self._server.list_tools() - async def list_tools() -> list[Tool]: - return list(mock_ref._tools.values()) - - @self._server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any] | None = None) -> list[TextContent]: - args = arguments or {} - handler = mock_ref._handlers.get(name) - if handler: - result = handler(args) - if inspect.isawaitable(result): - result = await result - else: - result = json.dumps({"mock": True, "tool": name}) + async def _call_tool(self, _ctx: Any, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + handler = self._handlers.get(params.name) + if handler: + result = handler(args) + if inspect.isawaitable(result): + result = await result + else: + result = json.dumps({"mock": True, "tool": params.name}) - if not isinstance(result, str): - result = json.dumps(result) + if not isinstance(result, str): + result = json.dumps(result) - call = ToolCall(tool_name=name, arguments=args, result=result) - mock_ref._call_log.append(call) + call = ToolCall(tool_name=params.name, arguments=args, result=result) + self._call_log.append(call) - return [TextContent(type="text", text=result)] + return CallToolResult(content=[TextContent(type="text", text=result)]) @property def call_log(self) -> list[ToolCall]: @@ -105,7 +97,7 @@ def register_tool( self._tools[name] = Tool( name=name, description=description or f"Mock {name}", - inputSchema=schema, + input_schema=schema, ) if handler: self._handlers[name] = handler @@ -150,7 +142,7 @@ def called_between(self, before: str, middle: str, after: str) -> bool: # --- Connection --- @asynccontextmanager - async def connect(self) -> AsyncGenerator[ClientSession, None]: + async def connect(self) -> AsyncGenerator[Client, None]: """Create an in-memory client session connected to this mock server.""" - async with create_connected_server_and_client_session(self._server) as session: - yield session + async with Client(self._server, mode="legacy") as client: + yield client diff --git a/tests/test_3tool_aliases.py b/tests/test_3tool_aliases.py index c6788065..2428075f 100644 --- a/tests/test_3tool_aliases.py +++ b/tests/test_3tool_aliases.py @@ -148,7 +148,7 @@ def test_search_mode_passes_order(self): def test_search_mode_requires_query(self): """mode=search without query returns error.""" result = asyncio.run(_brain_recall(mode="search", query=None)) - assert result.isError is True + assert result.is_error is True assert "query is required" in result.content[0].text @@ -194,7 +194,7 @@ def test_entity_mode_passes_include_audit(self): def test_entity_mode_requires_query(self): """mode=entity without query returns error.""" result = asyncio.run(_brain_recall(mode="entity", query=None)) - assert result.isError is True + assert result.is_error is True assert "query is required" in result.content[0].text @@ -324,7 +324,7 @@ def test_brain_update_returns_deprecation_error(self): from brainlayer.mcp import call_tool result = asyncio.run(call_tool("brain_update", {"action": "update", "chunk_id": "abc123"})) - assert result.isError is True + assert result.is_error is True assert "deprecated" in result.content[0].text.lower() assert "brain_store" in result.content[0].text or "brain_supersede" in result.content[0].text @@ -333,7 +333,7 @@ def test_brain_expand_returns_deprecation_error(self): from brainlayer.mcp import call_tool result = asyncio.run(call_tool("brain_expand", {"chunk_id": "abc123", "context": 3})) - assert result.isError is True + assert result.is_error is True assert "deprecated" in result.content[0].text.lower() assert "brain_recall" in result.content[0].text @@ -342,7 +342,7 @@ def test_brain_tags_returns_deprecation_error(self): from brainlayer.mcp import call_tool result = asyncio.run(call_tool("brain_tags", {"action": "list"})) - assert result.isError is True + assert result.is_error is True assert "deprecated" in result.content[0].text.lower() assert "brain_recall" in result.content[0].text or "brain_store" in result.content[0].text @@ -450,7 +450,7 @@ def test_brain_recall_has_search_and_entity_modes(self): tools = asyncio.run(list_tools()) recall_tool = next(t for t in tools if t.name == "brain_recall") - mode_enum = recall_tool.inputSchema["properties"]["mode"]["enum"] + mode_enum = recall_tool.input_schema["properties"]["mode"]["enum"] assert "search" in mode_enum assert "entity" in mode_enum @@ -465,7 +465,7 @@ def test_brain_recall_has_query_param(self): tools = asyncio.run(list_tools()) recall_tool = next(t for t in tools if t.name == "brain_recall") - assert "query" in recall_tool.inputSchema["properties"] + assert "query" in recall_tool.input_schema["properties"] def test_brain_recall_search_schema_has_order_param(self): """brain_recall search mode exposes origin ordering like brain_search.""" @@ -473,7 +473,7 @@ def test_brain_recall_search_schema_has_order_param(self): tools = asyncio.run(list_tools()) recall_tool = next(t for t in tools if t.name == "brain_recall") - order = recall_tool.inputSchema["properties"]["order"] + order = recall_tool.input_schema["properties"]["order"] assert order["type"] == "string" assert order["enum"] == ["relevance", "origin"] @@ -489,17 +489,17 @@ class TestEdgeCases: def test_unknown_mode_returns_error(self): """Explicit unknown mode returns error.""" result = asyncio.run(_brain_recall(mode="nonexistent")) - assert result.isError is True + assert result.is_error is True assert "Unknown recall mode" in result.content[0].text def test_operations_mode_requires_session_id(self): """mode=operations without session_id returns error.""" result = asyncio.run(_brain_recall(mode="operations")) - assert result.isError is True + assert result.is_error is True assert "session_id required" in result.content[0].text def test_summary_mode_requires_session_id(self): """mode=summary without session_id returns error.""" result = asyncio.run(_brain_recall(mode="summary")) - assert result.isError is True + assert result.is_error is True assert "session_id required" in result.content[0].text diff --git a/tests/test_audit_search_quality.py b/tests/test_audit_search_quality.py index 28157ce6..c2935e8e 100644 --- a/tests/test_audit_search_quality.py +++ b/tests/test_audit_search_quality.py @@ -113,7 +113,7 @@ async def test_mcp_recall_enum_matches_canonical(self): tools = await list_tools() recall_tool = next(t for t in tools if t.name == "brain_recall") - schema = recall_tool.inputSchema + schema = recall_tool.input_schema mcp_enum = schema["properties"]["entity_type"]["enum"] assert sorted(mcp_enum) == sorted(ENTITY_TYPES), ( @@ -126,7 +126,7 @@ async def test_mcp_entity_enum_matches_canonical(self): tools = _full_tool_definitions() entity_tool = next(t for t in tools if t.name == "brain_entity") - schema = entity_tool.inputSchema + schema = entity_tool.input_schema mcp_enum = schema["properties"]["entity_type"]["enum"] assert sorted(mcp_enum) == sorted(self.BRAIN_ENTITY_TYPES), ( diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py index fd092eb0..e37d39df 100644 --- a/tests/test_brainbar_hybrid_helper.py +++ b/tests/test_brainbar_hybrid_helper.py @@ -180,7 +180,7 @@ async def fake_brain_search(**kwargs): def test_helper_preserves_brain_search_mcp_error(monkeypatch, tmp_path): async def fake_brain_search(**_kwargs): - return CallToolResult(content=[TextContent(type="text", text="Invalid detail='verbose'")], isError=True) + return CallToolResult(content=[TextContent(type="text", text="Invalid detail='verbose'")], is_error=True) monkeypatch.setattr("brainlayer.mcp.search_handler._brain_search", fake_brain_search) diff --git a/tests/test_brainstore.py b/tests/test_brainstore.py index 2044dfc2..3dee8430 100644 --- a/tests/test_brainstore.py +++ b/tests/test_brainstore.py @@ -584,7 +584,7 @@ def test_store_tool_has_write_annotations(self): tools = asyncio.run(list_tools()) store_tool = next(t for t in tools if t.name == "brain_store") # Write tool should NOT be read-only - assert store_tool.annotations.readOnlyHint is False + assert store_tool.annotations.read_only_hint is False def test_store_tool_input_schema(self): """brain_store has correct required fields — only content required, type is optional.""" @@ -594,7 +594,7 @@ def test_store_tool_input_schema(self): tools = asyncio.run(list_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - schema = store_tool.inputSchema + schema = store_tool.input_schema assert "content" in schema["properties"] assert "type" in schema["properties"] assert "content" in schema["required"] diff --git a/tests/test_chunk_lifecycle.py b/tests/test_chunk_lifecycle.py index 8ed92ba5..ea2ea121 100644 --- a/tests/test_chunk_lifecycle.py +++ b/tests/test_chunk_lifecycle.py @@ -264,7 +264,7 @@ async def test_supersede_nonexistent_old(self, mock_embed): new = _store_chunk(self.store, mock_embed, "New thing") result = await _brain_supersede("nonexistent", new["id"]) - assert result.isError is True + assert result.is_error is True @pytest.mark.asyncio async def test_supersede_nonexistent_new(self, mock_embed): @@ -272,7 +272,7 @@ async def test_supersede_nonexistent_new(self, mock_embed): old = _store_chunk(self.store, mock_embed, "Old thing") result = await _brain_supersede(old["id"], "nonexistent") - assert result.isError is True + assert result.is_error is True # ── MCP Handler: brain_archive Tests ───────────────────────────────────────── @@ -311,7 +311,7 @@ async def test_archive_nonexistent(self): from brainlayer.mcp.store_handler import _brain_archive resp = await _brain_archive("nonexistent-id") - assert resp.isError is True + assert resp.is_error is True # ── brain_store with supersedes Tests ──────────────────────────────────────── diff --git a/tests/test_enrichment_controller.py b/tests/test_enrichment_controller.py index fbe19845..e3cba65d 100644 --- a/tests/test_enrichment_controller.py +++ b/tests/test_enrichment_controller.py @@ -1587,7 +1587,7 @@ async def test_brain_enrich_handler_returns_error_for_unknown_mode(monkeypatch): from brainlayer.mcp.enrich_handler import _brain_enrich result = await _brain_enrich(mode="unknown") - assert result.isError is True + assert result.is_error is True assert "Unknown mode" in result.content[0].text @@ -1603,7 +1603,7 @@ async def test_brain_enrich_handler_stats_mode(monkeypatch): monkeypatch.setattr("brainlayer.mcp.enrich_handler._get_vector_store", lambda: store) result = await _brain_enrich(stats=True) - assert result.isError is not True + assert result.is_error is not True text = result.content[0].text # _enrich_stats returns formatted text with box-drawing chars, not JSON assert "Total:" in text diff --git a/tests/test_entity_type_sync.py b/tests/test_entity_type_sync.py index 6db6c289..5dcc89e0 100644 --- a/tests/test_entity_type_sync.py +++ b/tests/test_entity_type_sync.py @@ -75,7 +75,7 @@ class TestEntityTypeEnum: def test_entity_type_enum_matches_hierarchy(self): """Every type in hierarchy seed must exist in the MCP enum.""" tool = _get_brain_entity_tool() - schema = tool.inputSchema + schema = tool.input_schema enum_values = set(schema["properties"]["entity_type"]["enum"]) missing = _HIERARCHY_SEED_TYPES - enum_values assert not missing, f"Hierarchy types missing from MCP enum: {missing}" @@ -83,7 +83,7 @@ def test_entity_type_enum_matches_hierarchy(self): def test_entity_type_enum_includes_extra_db_types(self): """Extra DB types (technology, library, company, location) must be in enum.""" tool = _get_brain_entity_tool() - schema = tool.inputSchema + schema = tool.input_schema enum_values = set(schema["properties"]["entity_type"]["enum"]) missing = _EXTRA_DB_TYPES - enum_values assert not missing, f"Extra DB types missing from MCP enum: {missing}" @@ -94,14 +94,14 @@ class TestEntitySchemaParams: def test_brain_entity_schema_has_action_param(self): tool = _get_brain_entity_tool() - props = tool.inputSchema["properties"] + props = tool.input_schema["properties"] assert "action" in props, "action param missing from brain_entity schema" assert props["action"]["enum"] == ["lookup", "list"] assert props["action"]["default"] == "lookup" def test_brain_entity_schema_has_limit_offset(self): tool = _get_brain_entity_tool() - props = tool.inputSchema["properties"] + props = tool.input_schema["properties"] assert "limit" in props, "limit param missing from brain_entity schema" assert props["limit"]["type"] == "integer" assert props["limit"]["default"] == 20 @@ -116,7 +116,7 @@ def test_brain_entity_schema_has_limit_offset(self): def test_brain_entity_query_not_required(self): """query should not be required (list action doesn't need it).""" tool = _get_brain_entity_tool() - required = tool.inputSchema.get("required", []) + required = tool.input_schema.get("required", []) assert "query" not in required, "query should not be required (list action doesn't use it)" diff --git a/tests/test_issue_type.py b/tests/test_issue_type.py index 2f163380..128f2df8 100644 --- a/tests/test_issue_type.py +++ b/tests/test_issue_type.py @@ -163,27 +163,27 @@ class TestIssueMCPSchema: def test_issue_in_type_enum(self): tools = asyncio.run(_get_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - type_enum = store_tool.inputSchema["properties"]["type"]["enum"] + type_enum = store_tool.input_schema["properties"]["type"]["enum"] assert "issue" in type_enum def test_status_field_exists(self): tools = asyncio.run(_get_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - props = store_tool.inputSchema["properties"] + props = store_tool.input_schema["properties"] assert "status" in props assert props["status"]["enum"] == ["open", "in_progress", "done", "archived"] def test_severity_field_exists(self): tools = asyncio.run(_get_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - props = store_tool.inputSchema["properties"] + props = store_tool.input_schema["properties"] assert "severity" in props assert props["severity"]["enum"] == ["critical", "high", "medium", "low"] def test_code_ref_fields_exist(self): tools = asyncio.run(_get_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - props = store_tool.inputSchema["properties"] + props = store_tool.input_schema["properties"] assert "file_path" in props assert "function_name" in props assert "line_number" in props diff --git a/tests/test_mcp_digest_modes.py b/tests/test_mcp_digest_modes.py index 63032dba..637dab0d 100644 --- a/tests/test_mcp_digest_modes.py +++ b/tests/test_mcp_digest_modes.py @@ -74,7 +74,7 @@ async def test_brain_digest_missing_content_with_mode_digest_errors(): result = await _brain_digest(content=None, mode="digest") - assert result.isError is True + assert result.is_error is True assert "content is required" in result.content[0].text.lower() @@ -83,7 +83,7 @@ def test_brain_digest_input_schema_includes_mode_and_limit(): tools = _full_tool_definitions() digest = next(t for t in tools if t.name == "brain_digest") - props = digest.inputSchema["properties"] + props = digest.input_schema["properties"] assert "mode" in props assert props["mode"]["enum"] == ["digest", "enrich", "connect"] diff --git a/tests/test_mcp_input_schema_limits.py b/tests/test_mcp_input_schema_limits.py index 967cd36c..3fb01a96 100644 --- a/tests/test_mcp_input_schema_limits.py +++ b/tests/test_mcp_input_schema_limits.py @@ -3,7 +3,8 @@ import asyncio from typing import Any -from mcp import types +from mcp.client import Client +from mcp.types import TextContent import brainlayer.mcp as mcp_module from brainlayer.mcp import _full_tool_definitions, server @@ -48,7 +49,7 @@ def _iter_string_arrays(schema: dict[str, Any], path: str = ""): def test_all_string_input_fields_have_max_length_and_string_arrays_have_max_items(): for tool in _get_tools(): - schema = tool.inputSchema + schema = tool.input_schema for field_path, string_schema in _iter_string_fields(schema): assert "maxLength" in string_schema, f"{tool.name}.{field_path} is missing maxLength" @@ -58,17 +59,54 @@ def test_all_string_input_fields_have_max_length_and_string_arrays_have_max_item async def _call_brain_digest(arguments: dict[str, Any]): - handler = server.request_handlers[types.CallToolRequest] - request = types.CallToolRequest(params=types.CallToolRequestParams(name="brain_digest", arguments=arguments)) - return await handler(request) + async with Client(server, mode="legacy") as client: + return await client.call_tool("brain_digest", arguments) def test_brain_digest_schema_rejects_oversized_content(monkeypatch): monkeypatch.setattr(mcp_module, "_tool_palette", ToolPalette("full")) - result = asyncio.run(_call_brain_digest({"content": "x" * 200_001})).root + result = asyncio.run(_call_brain_digest({"content": "x" * 200_001})) - assert result.isError is True + assert result.is_error is True assert result.content, "Expected error content to be non-empty" text = result.content[0].text assert "Input validation error:" in text assert "is too long" in text + + +def test_mcp_v2_adapter_preserves_combined_tool_results(monkeypatch): + async def fake_store_new(**_kwargs): + return ( + [TextContent(type="text", text="stored")], + {"chunk_id": "chunk-1", "related": []}, + ) + + monkeypatch.setattr(mcp_module, "_tool_palette", ToolPalette("full")) + monkeypatch.setattr(mcp_module, "_store_new", fake_store_new) + + async def call_store(): + async with Client(server, mode="legacy") as client: + return await client.call_tool("brain_store", {"content": "remember this"}) + + result = asyncio.run(call_store()) + + assert result.is_error is False + assert result.content[0].text == "stored" + assert result.structured_content == {"chunk_id": "chunk-1", "related": []} + + +def test_mcp_v2_adapter_preserves_brain_store_error_message(monkeypatch): + async def failing_store_new(**_kwargs): + return mcp_module._error_result("Store failed: database is locked") + + monkeypatch.setattr(mcp_module, "_tool_palette", ToolPalette("full")) + monkeypatch.setattr(mcp_module, "_store_new", failing_store_new) + + async def call_store(): + async with Client(server, mode="legacy") as client: + return await client.call_tool("brain_store", {"content": "remember this"}) + + result = asyncio.run(call_store()) + + assert result.is_error is True + assert result.content[0].text == "Store failed: database is locked" diff --git a/tests/test_mcp_labeled_field_output.py b/tests/test_mcp_labeled_field_output.py index daa1d7f6..5177b19b 100644 --- a/tests/test_mcp_labeled_field_output.py +++ b/tests/test_mcp_labeled_field_output.py @@ -269,6 +269,5 @@ def test_brain_recall_tool_declares_anthropic_max_result_size(): tools = asyncio.run(list_tools()) recall = next(tool for tool in tools if tool.name == "brain_recall") - annotation_dump = recall.annotations.model_dump(by_alias=True) - assert annotation_dump["anthropic/maxResultSizeChars"] > 200_000 + assert recall.meta["anthropic/maxResultSizeChars"] > 200_000 diff --git a/tests/test_mcp_palette.py b/tests/test_mcp_palette.py index 543af736..72a4da82 100644 --- a/tests/test_mcp_palette.py +++ b/tests/test_mcp_palette.py @@ -42,12 +42,12 @@ def test_python_palette_expands_once_and_dispatches_deferred_tools(monkeypatch): assert tuple(tool.name for tool in asyncio.run(list_tools())) == CORE_WITH_CONTROL before = asyncio.run(call_tool("brain_tags", {})) - assert before.isError is True + assert before.is_error is True assert "not exposed" in before.content[0].text first = asyncio.run(call_tool("expand_palette", {})) - assert first.isError is False - assert first.structuredContent == { + assert first.is_error is False + assert first.structured_content == { "expanded": True, "already_expanded": False, "registered_tools": [tool.name for tool in _full_tool_definitions() if tool.name not in CORE_TOOL_NAMES], @@ -58,7 +58,7 @@ def test_python_palette_expands_once_and_dispatches_deferred_tools(monkeypatch): assert "deprecated" in after.content[0].text second = asyncio.run(call_tool("expand_palette", {})) - assert second.structuredContent == { + assert second.structured_content == { "expanded": False, "already_expanded": True, "registered_tools": [], diff --git a/tests/test_mcp_timeout.py b/tests/test_mcp_timeout.py index d52c8f78..81b459ed 100644 --- a/tests/test_mcp_timeout.py +++ b/tests/test_mcp_timeout.py @@ -13,5 +13,5 @@ async def test_with_timeout_reads_timeout_budget_at_call_time(monkeypatch): result = await asyncio.wait_for(mcp._with_timeout(asyncio.sleep(10)), timeout=0.1) - assert result.isError is True + assert result.is_error is True assert "BrainLayer timeout (0.001s)" in result.content[0].text diff --git a/tests/test_mcp_warm_route.py b/tests/test_mcp_warm_route.py index 3689fa6c..2f50b5ca 100644 --- a/tests/test_mcp_warm_route.py +++ b/tests/test_mcp_warm_route.py @@ -269,7 +269,7 @@ async def cold_dispatch(**_kwargs): result = await _brain_search(query="invalid helper response") - assert result.isError is True + assert result.is_error is True assert result.content[0].text == "Invalid detail='verbose'" diff --git a/tests/test_phase3_digest.py b/tests/test_phase3_digest.py index 1924bcdd..66b57b84 100644 --- a/tests/test_phase3_digest.py +++ b/tests/test_phase3_digest.py @@ -302,7 +302,7 @@ def test_brain_digest_schema_has_required_fields(): tools = _full_tool_definitions() digest = next(t for t in tools if t.name == "brain_digest") - props = digest.inputSchema.get("properties", {}) + props = digest.input_schema.get("properties", {}) assert "content" in props assert "title" in props assert "participants" in props @@ -346,7 +346,7 @@ def test_brain_entity_schema(): tools = _full_tool_definitions() entity_tool = next(t for t in tools if t.name == "brain_entity") - props = entity_tool.inputSchema.get("properties", {}) + props = entity_tool.input_schema.get("properties", {}) assert "query" in props diff --git a/tests/test_phase5.py b/tests/test_phase5.py index abdffb42..929adde2 100644 --- a/tests/test_phase5.py +++ b/tests/test_phase5.py @@ -302,7 +302,7 @@ def test_brain_store_schema_has_decision_fields(self): tools = asyncio.run(list_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - props = store_tool.inputSchema["properties"] + props = store_tool.input_schema["properties"] assert "confidence_score" in props assert "outcome" in props @@ -328,5 +328,5 @@ def test_auto_importance_applied(self): tools = asyncio.run(list_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - desc = store_tool.inputSchema["properties"]["importance"]["description"] + desc = store_tool.input_schema["properties"]["importance"]["description"] assert "auto" in desc.lower() or "Auto" in desc diff --git a/tests/test_phase6_sentiment.py b/tests/test_phase6_sentiment.py index f4f41135..67406054 100644 --- a/tests/test_phase6_sentiment.py +++ b/tests/test_phase6_sentiment.py @@ -192,7 +192,7 @@ def test_brain_search_schema_has_sentiment(): tools = asyncio.run(list_tools()) brain_search = next(t for t in tools if t.name == "brain_search") - props = brain_search.inputSchema.get("properties", {}) + props = brain_search.input_schema.get("properties", {}) assert "sentiment" in props assert props["sentiment"]["type"] == "string" diff --git a/tests/test_precompact_chunk_origin.py b/tests/test_precompact_chunk_origin.py index eff1fcfd..243eadc4 100644 --- a/tests/test_precompact_chunk_origin.py +++ b/tests/test_precompact_chunk_origin.py @@ -943,7 +943,7 @@ def _read_cursor(self): result = asyncio.run(_brain_resume()) - assert result.isError is True + assert result.is_error is True assert result.content[0].text == "Resume error: boom" @@ -983,5 +983,5 @@ def raise_store_error(): result = asyncio.run(_brain_resume()) - assert result.isError is True + assert result.is_error is True assert result.content[0].text == "Resume error: boom" diff --git a/tests/test_search_fanout.py b/tests/test_search_fanout.py index ffead0c8..fe8923d0 100644 --- a/tests/test_search_fanout.py +++ b/tests/test_search_fanout.py @@ -268,7 +268,7 @@ async def fake_search(**kwargs): if kwargs.get("date_from"): return CallToolResult( content=[TextContent(type="text", text="Search error: database busy")], - isError=True, + is_error=True, ) return _structured("good-hit") @@ -296,7 +296,7 @@ async def fake_search(**kwargs): async def test_brain_search_schema_exposes_opt_in_fan_out(): search_tool = next(tool for tool in await list_tools() if tool.name == "brain_search") - fan_out = search_tool.inputSchema["properties"]["fan_out"] + fan_out = search_tool.input_schema["properties"]["fan_out"] assert fan_out == { "type": "boolean", "default": False, diff --git a/tests/test_search_filter_params.py b/tests/test_search_filter_params.py index 721c3647..bfc6af63 100644 --- a/tests/test_search_filter_params.py +++ b/tests/test_search_filter_params.py @@ -529,7 +529,7 @@ def _get_brain_search_props(self): tools = asyncio.run(list_tools()) brain_search = next(t for t in tools if t.name == "brain_search") - return brain_search.inputSchema["properties"] + return brain_search.input_schema["properties"] def test_new_params_in_schema(self): """brain_search tool schema includes all 4 new filter params.""" @@ -807,7 +807,7 @@ def test_brain_resume_tool_schema(self): tools = _full_tool_definitions() brain_resume = next(tool for tool in tools if tool.name == "brain_resume") - props = brain_resume.inputSchema["properties"] + props = brain_resume.input_schema["properties"] assert props["session_id"]["type"] == "string" assert props["lookback_days"]["type"] == "integer" diff --git a/tests/test_search_validation.py b/tests/test_search_validation.py index 2ba7c724..f6c0b124 100644 --- a/tests/test_search_validation.py +++ b/tests/test_search_validation.py @@ -48,7 +48,7 @@ def test_detail_verbose_returns_error(self): # After fix: _search is NOT called — error returned before routing mock_search.assert_not_called() - assert result.isError is True + assert result.is_error is True assert "Invalid detail='verbose'" in result.content[0].text def test_detail_empty_string_returns_error(self): @@ -63,7 +63,7 @@ def test_detail_empty_string_returns_error(self): result = asyncio.run(_brain_search(query="test", detail="", project="test")) mock_search.assert_not_called() - assert result.isError is True + assert result.is_error is True assert "Must be one of" in result.content[0].text def test_detail_none_returns_error(self): @@ -78,7 +78,7 @@ def test_detail_none_returns_error(self): result = asyncio.run(_brain_search(query="test", detail=None, project="test")) mock_search.assert_not_called() - assert result.isError is True + assert result.is_error is True assert "Invalid detail='None'" in result.content[0].text def test_detail_compact_is_valid(self): @@ -131,7 +131,7 @@ def test_num_results_over_100_returns_error(self): result = asyncio.run(_brain_search(query="test", num_results=101, project="test")) mock_search.assert_not_called() - assert result.isError is True + assert result.is_error is True assert "must be between 1 and 100" in result.content[0].text @pytest.mark.parametrize("num_results", [0, -1]) @@ -147,7 +147,7 @@ def test_num_results_below_one_returns_error(self, num_results): result = asyncio.run(_brain_search(query="test", num_results=num_results, project="test")) mock_search.assert_not_called() - assert result.isError is True + assert result.is_error is True assert f"num_results={num_results}" in result.content[0].text diff --git a/tests/test_smart_search_entity_dedup.py b/tests/test_smart_search_entity_dedup.py index 1237d53d..53848583 100644 --- a/tests/test_smart_search_entity_dedup.py +++ b/tests/test_smart_search_entity_dedup.py @@ -169,10 +169,10 @@ def test_brain_expand_schema(self): tools = asyncio.run(list_tools()) expand_tool = next(t for t in tools if t.name == "brain_expand") - props = expand_tool.inputSchema.get("properties", {}) + props = expand_tool.input_schema.get("properties", {}) assert "chunk_id" in props assert "context" in props - assert "chunk_id" in expand_tool.inputSchema.get("required", []) + assert "chunk_id" in expand_tool.input_schema.get("required", []) def test_brain_expand_returns_target_chunk(self, tmp_path): """brain_expand returns at least the target chunk content.""" @@ -228,7 +228,7 @@ def test_brain_expand_manual_chunk_id_returns_target_content(self, tmp_path, mon # It returns isError: true with a deprecation message. result = asyncio.run(call_tool("brain_expand", {"chunk_id": stored["id"]})) - assert result.isError is True + assert result.is_error is True assert "deprecated" in result.content[0].text.lower() # Equivalent via brain_search chunk_id expansion still works: @@ -380,7 +380,7 @@ def test_brain_search_has_detail_param(self): tools = asyncio.run(list_tools()) search_tool = next(t for t in tools if t.name == "brain_search") - props = search_tool.inputSchema.get("properties", {}) + props = search_tool.input_schema.get("properties", {}) assert "detail" in props assert props["detail"]["default"] == "compact" @@ -390,7 +390,7 @@ def test_brain_search_detail_enum_values(self): tools = asyncio.run(list_tools()) search_tool = next(t for t in tools if t.name == "brain_search") - detail_schema = search_tool.inputSchema["properties"]["detail"] + detail_schema = search_tool.input_schema["properties"]["detail"] assert set(detail_schema.get("enum", [])) == {"compact", "full"} def test_server_instructions_mention_brain_expand(self): diff --git a/tests/test_store_handler.py b/tests/test_store_handler.py index 6c2dd267..4cbedca4 100644 --- a/tests/test_store_handler.py +++ b/tests/test_store_handler.py @@ -176,7 +176,7 @@ async def test_store_validates_before_busy_deferral(tmp_path): project="test", ) - assert result.isError is True + assert result.is_error is True assert "Validation error" in result.content[0].text assert not list(queue_dir.glob("mcp-*.jsonl")) diff --git a/tests/test_think_recall_integration.py b/tests/test_think_recall_integration.py index a02d7897..7e15b45f 100644 --- a/tests/test_think_recall_integration.py +++ b/tests/test_think_recall_integration.py @@ -275,4 +275,4 @@ def test_read_tools_have_annotations(self): read_tools = [t for t in tools if t.name in ("brain_search", "brain_recall")] for tool in read_tools: assert tool.annotations is not None - assert tool.annotations.readOnlyHint is True + assert tool.annotations.read_only_hint is True diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index 4f0d9960..ed192749 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -88,37 +88,37 @@ def test_annotations_have_all_three_hints(self): continue ann = tool.annotations assert ann is not None, f"{tool.name}: annotations is None" - assert ann.readOnlyHint is not None, f"{tool.name}: readOnlyHint is None" - assert ann.destructiveHint is not None, f"{tool.name}: destructiveHint is None" - assert ann.idempotentHint is not None, f"{tool.name}: idempotentHint is None" + assert ann.read_only_hint is not None, f"{tool.name}: readOnlyHint is None" + assert ann.destructive_hint is not None, f"{tool.name}: destructiveHint is None" + assert ann.idempotent_hint is not None, f"{tool.name}: idempotentHint is None" @pytest.mark.parametrize("tool_name", sorted(READ_ONLY_TOOLS)) def test_read_only_tools(self, tool_name): - """Read-only tools must have readOnlyHint=True.""" + """Read-only tools must have read_only_hint=True.""" tools = self._get_tools() tool = next(t for t in tools if t.name == tool_name) - assert tool.annotations.readOnlyHint is True, f"{tool_name} should be readOnly" + assert tool.annotations.read_only_hint is True, f"{tool_name} should be readOnly" @pytest.mark.parametrize("tool_name", sorted(WRITE_TOOLS)) def test_write_tools(self, tool_name): - """Write tools must have readOnlyHint=False.""" + """Write tools must have read_only_hint=False.""" tools = self._get_tools() tool = next(t for t in tools if t.name == tool_name) - assert tool.annotations.readOnlyHint is False, f"{tool_name} should not be readOnly" + assert tool.annotations.read_only_hint is False, f"{tool_name} should not be readOnly" @pytest.mark.parametrize("tool_name", sorted(DESTRUCTIVE_TOOLS)) def test_destructive_tools(self, tool_name): - """Destructive tools must have destructiveHint=True.""" + """Destructive tools must have destructive_hint=True.""" tools = self._get_tools() tool = next(t for t in tools if t.name == tool_name) - assert tool.annotations.destructiveHint is True, f"{tool_name} should be destructive" + assert tool.annotations.destructive_hint is True, f"{tool_name} should be destructive" @pytest.mark.parametrize("tool_name", sorted(IDEMPOTENT_TOOLS)) def test_idempotent_tools(self, tool_name): - """Idempotent tools must have idempotentHint=True.""" + """Idempotent tools must have idempotent_hint=True.""" tools = self._get_tools() tool = next(t for t in tools if t.name == tool_name) - assert tool.annotations.idempotentHint is True, f"{tool_name} should be idempotent" + assert tool.annotations.idempotent_hint is True, f"{tool_name} should be idempotent" # ── B7: agent_id scoping on brain_store ──────────────────────────── @@ -133,7 +133,7 @@ def test_brain_store_has_agent_id_param(self): tools = asyncio.run(list_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - props = store_tool.inputSchema["properties"] + props = store_tool.input_schema["properties"] assert "agent_id" in props, "brain_store must have agent_id parameter" assert props["agent_id"]["type"] == "string" @@ -143,5 +143,5 @@ def test_agent_id_not_required(self): tools = asyncio.run(list_tools()) store_tool = next(t for t in tools if t.name == "brain_store") - required = store_tool.inputSchema.get("required", []) + required = store_tool.input_schema.get("required", []) assert "agent_id" not in required diff --git a/tests/test_write_queue.py b/tests/test_write_queue.py index e2553a73..5358f926 100644 --- a/tests/test_write_queue.py +++ b/tests/test_write_queue.py @@ -1478,7 +1478,7 @@ async def test_arbitrated_store_validates_before_queueing(self, tmp_path, monkey with patch("brainlayer.queue_io.get_queue_dir", return_value=tmp_path): result = await _store(content=" ", memory_type="note", project="test") - assert result.isError is True + assert result.is_error is True assert "content must be non-empty" in result.content[0].text assert not list(tmp_path.glob("mcp-*.jsonl")) @@ -2471,7 +2471,7 @@ async def test_update_fails_after_max_retries(self): from mcp.types import CallToolResult assert isinstance(result, CallToolResult) - assert result.isError is True + assert result.is_error is True class TestBrainSearchRetryOnLock: