Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider pinning an upper bound for jsonschema.

The mcp dependency uses <3.0.0 to guard against a future breaking major release. jsonschema>=4.20.0 has no upper bound. Add a similar cap for consistency and to avoid an untested future major version breaking schema validation.

♻️ Proposed pin
-    "jsonschema>=4.20.0",  # MCP v2 low-level handlers require explicit schema validation
+    "jsonschema>=4.20.0,<5.0.0",  # MCP v2 low-level handlers require explicit schema validation
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"mcp>=2.0.0,<3.0.0",
"jsonschema>=4.20.0", # MCP v2 low-level handlers require explicit schema validation
"mcp>=2.0.0,<3.0.0",
"jsonschema>=4.20.0,<5.0.0", # MCP v2 low-level handlers require explicit schema validation
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 19 - 20, Update the jsonschema dependency
specification in pyproject.toml to add an upper bound excluding the next major
version, matching the existing mcp dependency constraint while preserving the
current minimum version.


# CLI
"typer>=0.9.0",
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke/repogolem_mcp_transport_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/brainlayer/brainbar_hybrid_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 111 additions & 47 deletions src/brainlayer/mcp/__init__.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/brainlayer/mcp/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/brainlayer/mcp/palette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 25 additions & 33 deletions tests/mock_mcp/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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]:
Expand All @@ -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
Expand Down Expand Up @@ -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
22 changes: 11 additions & 11 deletions tests/test_3tool_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -465,15 +465,15 @@ 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."""
from brainlayer.mcp import list_tools

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"]
Expand All @@ -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
4 changes: 2 additions & 2 deletions tests/test_audit_search_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -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), (
Expand All @@ -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), (
Expand Down
2 changes: 1 addition & 1 deletion tests/test_brainbar_hybrid_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions tests/test_brainstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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"]
Expand Down
6 changes: 3 additions & 3 deletions tests/test_chunk_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,15 @@ 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):
from brainlayer.mcp.store_handler import _brain_supersede

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 ─────────────────────────────────────────
Expand Down Expand Up @@ -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 ────────────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions tests/test_enrichment_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions tests/test_entity_type_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,15 @@ 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}"

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}"
Expand All @@ -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
Expand All @@ -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)"


Expand Down
Loading
Loading