diff --git a/src/basic_memory/cli/commands/ci.py b/src/basic_memory/cli/commands/ci.py index d44887e3..202f176f 100644 --- a/src/basic_memory/cli/commands/ci.py +++ b/src/basic_memory/cli/commands/ci.py @@ -34,8 +34,10 @@ from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.cli.commands.routing import force_routing, validate_routing_flags -from basic_memory.mcp.tools import search_notes as mcp_search_notes -from basic_memory.mcp.tools import write_note as mcp_write_note + +# MCP tool functions are imported inside the async helpers below: importing +# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK, +# SQLAlchemy), which would slow every CLI invocation, including --help (#886). console = Console() @@ -252,6 +254,10 @@ async def seed_project_update_schemas( refresh: bool = False, ) -> list[str]: """Seed Auto BM schema notes without overwriting customized schemas.""" + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import search_notes as mcp_search_notes + from basic_memory.mcp.tools import write_note as mcp_write_note + seeded: list[str] = [] routed_project = _routed_project(project=project, project_id=project_id, workspace=workspace) for spec in schema_seed_specs(): @@ -295,6 +301,10 @@ async def publish_project_update_note( note: ProjectUpdateNote, ) -> dict[str, Any]: """Search by idempotency key and then upsert the deterministic note path.""" + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import search_notes as mcp_search_notes + from basic_memory.mcp.tools import write_note as mcp_write_note + routed_project = _routed_project( project=config.project, project_id=config.project_id, diff --git a/src/basic_memory/cli/commands/command_utils.py b/src/basic_memory/cli/commands/command_utils.py index 33ec2f8c..d57f73b5 100644 --- a/src/basic_memory/cli/commands/command_utils.py +++ b/src/basic_memory/cli/commands/command_utils.py @@ -3,12 +3,10 @@ import asyncio from typing import Optional, TypeVar, Coroutine, Any -from mcp.server.fastmcp.exceptions import ToolError import typer from rich.console import Console -from basic_memory import db from basic_memory.config import ConfigManager from basic_memory.mcp.async_client import get_client from basic_memory.mcp.clients import ProjectClient @@ -31,6 +29,9 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T: Returns: The result of the coroutine """ + # Deferred: basic_memory.db pulls SQLAlchemy + Alembic, which must not load + # at CLI import time — only when a command actually runs (#886). + from basic_memory import db async def _with_cleanup() -> T: try: @@ -53,6 +54,8 @@ async def run_sync( force_full: If True, force a full scan bypassing watermark optimization run_in_background: If True, return immediately; if False, wait for completion """ + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError # Resolve default project so get_client() can route per-project project = project or ConfigManager().default_project @@ -86,6 +89,9 @@ async def run_sync( async def get_project_info(project: str): """Get project information via API endpoint.""" + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError + try: async with get_client(project_name=project) as client: project_item = await get_active_project(client, project, None) diff --git a/src/basic_memory/cli/commands/db.py b/src/basic_memory/cli/commands/db.py index 532bc9bb..7e99d230 100644 --- a/src/basic_memory/cli/commands/db.py +++ b/src/basic_memory/cli/commands/db.py @@ -1,24 +1,27 @@ """Database management commands.""" +# PEP 563 lazy annotations let signatures reference IndexProgress without importing +# the indexing stack at module load; reset/reindex import their heavy database and +# sync dependencies at call time so CLI startup stays fast (#886). +from __future__ import annotations + import os from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import TYPE_CHECKING import psutil import typer from loguru import logger from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn -from sqlalchemy.exc import OperationalError -from basic_memory import db from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager, ProjectMode -from basic_memory.indexing import IndexProgress -from basic_memory.repository import ProjectRepository -from basic_memory.services.initialization import reconcile_projects_with_config -from basic_memory.sync.sync_service import get_sync_service + +if TYPE_CHECKING: + from basic_memory.indexing import IndexProgress console = Console() @@ -159,6 +162,13 @@ async def _reindex_projects(app_config): This ensures all database operations use the same event loop, and proper cleanup happens when the function completes. """ + # Deferred: SQLAlchemy, repositories, and the sync stack load only when a + # reindex actually runs, not on every CLI start (#886). + from basic_memory import db + from basic_memory.repository import ProjectRepository + from basic_memory.services.initialization import reconcile_projects_with_config + from basic_memory.sync.sync_service import get_sync_service + try: await reconcile_projects_with_config(app_config) @@ -197,6 +207,12 @@ def reset( ), ): # pragma: no cover """Reset database (drop all tables and recreate).""" + # Deferred: SQLAlchemy and the db module load only when a reset actually + # runs, not on every CLI start (#886). + from sqlalchemy.exc import OperationalError + + from basic_memory import db + console.print( "[yellow]Note:[/yellow] This only deletes the index database. " "Your markdown note files will not be affected.\n" @@ -320,10 +336,15 @@ async def _reindex( project: str | None, ): """Run reindex operations.""" - from basic_memory.repository import EntityRepository + # Deferred: SQLAlchemy, repositories, and the sync stack load only when a + # reindex actually runs, not on every CLI start (#886). + from basic_memory import db + from basic_memory.repository import EntityRepository, ProjectRepository from basic_memory.repository.search_repository import create_search_repository + from basic_memory.services.initialization import reconcile_projects_with_config from basic_memory.services.search_service import SearchService from basic_memory.services.file_service import FileService + from basic_memory.sync.sync_service import get_sync_service from basic_memory.markdown.markdown_processor import MarkdownProcessor from basic_memory.markdown.entity_parser import EntityParser diff --git a/src/basic_memory/cli/commands/doctor.py b/src/basic_memory/cli/commands/doctor.py index defcabe2..7e2caa89 100644 --- a/src/basic_memory/cli/commands/doctor.py +++ b/src/basic_memory/cli/commands/doctor.py @@ -7,16 +7,12 @@ from pathlib import Path from loguru import logger -from mcp.server.fastmcp.exceptions import ToolError from rich.console import Console import typer from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.cli.commands.routing import force_routing, validate_routing_flags -from basic_memory.markdown.entity_parser import EntityParser -from basic_memory.markdown.markdown_processor import MarkdownProcessor -from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown from basic_memory.mcp.async_client import get_client from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient from basic_memory.schemas.base import Entity @@ -29,6 +25,12 @@ async def run_doctor() -> None: """Run local consistency checks for file <-> database flows.""" + # Deferred: the markdown parsing stack is only needed while the checks run, + # and importing it at module level slows every CLI invocation (#886). + from basic_memory.markdown.entity_parser import EntityParser + from basic_memory.markdown.markdown_processor import MarkdownProcessor + from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown + console.print("[blue]Running Basic Memory doctor checks...[/blue]") project_name = f"doctor-{uuid.uuid4().hex[:8]}" @@ -140,6 +142,9 @@ def doctor( cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), ) -> None: """Run local consistency checks to verify file/database sync.""" + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError + try: validate_routing_flags(local, cloud) # Doctor runs local filesystem checks — always default to local routing diff --git a/src/basic_memory/cli/commands/import_chatgpt.py b/src/basic_memory/cli/commands/import_chatgpt.py index 1fac9d9a..d02e2088 100644 --- a/src/basic_memory/cli/commands/import_chatgpt.py +++ b/src/basic_memory/cli/commands/import_chatgpt.py @@ -1,25 +1,34 @@ """Import command for ChatGPT conversations.""" +# PEP 563 lazy annotations keep heavy importer types out of module import (#886). +from __future__ import annotations + import json from pathlib import Path -from typing import Annotated, Tuple +from typing import TYPE_CHECKING, Annotated, Tuple import typer from basic_memory.cli.app import import_app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager, get_project_config -from basic_memory.importers import ChatGPTImporter -from basic_memory.markdown import EntityParser, MarkdownProcessor -from basic_memory.services.file_service import FileService from loguru import logger from rich.console import Console from rich.panel import Panel +if TYPE_CHECKING: + from basic_memory.markdown import MarkdownProcessor + from basic_memory.services.file_service import FileService + console = Console() async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]: """Get MarkdownProcessor and FileService instances for importers.""" + # Deferred: the markdown/file-service stack pulls SQLAlchemy and must load + # only when an import actually runs, not on every CLI start (#886). + from basic_memory.markdown import EntityParser, MarkdownProcessor + from basic_memory.services.file_service import FileService + config = get_project_config() app_config = ConfigManager().config entity_parser = EntityParser(config.home) @@ -60,6 +69,9 @@ def import_chatgpt( console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}") # Create importer and run import + # Deferred: importer stack loads at import-command run time only (#886). + from basic_memory.importers import ChatGPTImporter + importer = ChatGPTImporter( config.home, markdown_processor, file_service, project_name=config.name ) diff --git a/src/basic_memory/cli/commands/import_claude_conversations.py b/src/basic_memory/cli/commands/import_claude_conversations.py index c852ae79..77e7bfbe 100644 --- a/src/basic_memory/cli/commands/import_claude_conversations.py +++ b/src/basic_memory/cli/commands/import_claude_conversations.py @@ -1,25 +1,34 @@ """Import command for basic-memory CLI to import chat data from conversations2.json format.""" +# PEP 563 lazy annotations keep heavy importer types out of module import (#886). +from __future__ import annotations + import json from pathlib import Path -from typing import Annotated, Tuple +from typing import TYPE_CHECKING, Annotated, Tuple import typer from basic_memory.cli.app import claude_app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager, get_project_config -from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter -from basic_memory.markdown import EntityParser, MarkdownProcessor -from basic_memory.services.file_service import FileService from loguru import logger from rich.console import Console from rich.panel import Panel +if TYPE_CHECKING: + from basic_memory.markdown import MarkdownProcessor + from basic_memory.services.file_service import FileService + console = Console() async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]: """Get MarkdownProcessor and FileService instances for importers.""" + # Deferred: the markdown/file-service stack pulls SQLAlchemy and must load + # only when an import actually runs, not on every CLI start (#886). + from basic_memory.markdown import EntityParser, MarkdownProcessor + from basic_memory.services.file_service import FileService + config = get_project_config() app_config = ConfigManager().config entity_parser = EntityParser(config.home) @@ -57,6 +66,9 @@ def import_claude( markdown_processor, file_service = run_with_cleanup(get_importer_dependencies()) # Create the importer + # Deferred: importer stack loads at import-command run time only (#886). + from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter + importer = ClaudeConversationsImporter( config.home, markdown_processor, file_service, project_name=config.name ) diff --git a/src/basic_memory/cli/commands/import_claude_projects.py b/src/basic_memory/cli/commands/import_claude_projects.py index 4b8de75d..d40a7a0a 100644 --- a/src/basic_memory/cli/commands/import_claude_projects.py +++ b/src/basic_memory/cli/commands/import_claude_projects.py @@ -1,25 +1,34 @@ """Import command for basic-memory CLI to import project data from Claude.ai.""" +# PEP 563 lazy annotations keep heavy importer types out of module import (#886). +from __future__ import annotations + import json from pathlib import Path -from typing import Annotated, Tuple +from typing import TYPE_CHECKING, Annotated, Tuple import typer from basic_memory.cli.app import claude_app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager, get_project_config -from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter -from basic_memory.markdown import EntityParser, MarkdownProcessor -from basic_memory.services.file_service import FileService from loguru import logger from rich.console import Console from rich.panel import Panel +if TYPE_CHECKING: + from basic_memory.markdown import MarkdownProcessor + from basic_memory.services.file_service import FileService + console = Console() async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]: """Get MarkdownProcessor and FileService instances for importers.""" + # Deferred: the markdown/file-service stack pulls SQLAlchemy and must load + # only when an import actually runs, not on every CLI start (#886). + from basic_memory.markdown import EntityParser, MarkdownProcessor + from basic_memory.services.file_service import FileService + config = get_project_config() app_config = ConfigManager().config entity_parser = EntityParser(config.home) @@ -56,6 +65,9 @@ def import_projects( markdown_processor, file_service = run_with_cleanup(get_importer_dependencies()) # Create the importer + # Deferred: importer stack loads at import-command run time only (#886). + from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter + importer = ClaudeProjectsImporter( config.home, markdown_processor, file_service, project_name=config.name ) diff --git a/src/basic_memory/cli/commands/import_memory_json.py b/src/basic_memory/cli/commands/import_memory_json.py index ddb72262..7717f7f2 100644 --- a/src/basic_memory/cli/commands/import_memory_json.py +++ b/src/basic_memory/cli/commands/import_memory_json.py @@ -1,25 +1,34 @@ """Import command for basic-memory CLI to import from JSON memory format.""" +# PEP 563 lazy annotations keep heavy importer types out of module import (#886). +from __future__ import annotations + import json from pathlib import Path -from typing import Annotated, Tuple +from typing import TYPE_CHECKING, Annotated, Tuple import typer from basic_memory.cli.app import import_app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager, get_project_config -from basic_memory.importers.memory_json_importer import MemoryJsonImporter -from basic_memory.markdown import EntityParser, MarkdownProcessor -from basic_memory.services.file_service import FileService from loguru import logger from rich.console import Console from rich.panel import Panel +if TYPE_CHECKING: + from basic_memory.markdown import MarkdownProcessor + from basic_memory.services.file_service import FileService + console = Console() async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]: """Get MarkdownProcessor and FileService instances for importers.""" + # Deferred: the markdown/file-service stack pulls SQLAlchemy and must load + # only when an import actually runs, not on every CLI start (#886). + from basic_memory.markdown import EntityParser, MarkdownProcessor + from basic_memory.services.file_service import FileService + config = get_project_config() app_config = ConfigManager().config entity_parser = EntityParser(config.home) @@ -55,6 +64,9 @@ def memory_json( markdown_processor, file_service = run_with_cleanup(get_importer_dependencies()) # Create the importer + # Deferred: importer stack loads at import-command run time only (#886). + from basic_memory.importers.memory_json_importer import MemoryJsonImporter + importer = MemoryJsonImporter( config.home, markdown_processor, file_service, project_name=config.name ) diff --git a/src/basic_memory/cli/commands/orphans.py b/src/basic_memory/cli/commands/orphans.py index 6579588c..40cce8ad 100644 --- a/src/basic_memory/cli/commands/orphans.py +++ b/src/basic_memory/cli/commands/orphans.py @@ -5,7 +5,6 @@ import typer from loguru import logger -from mcp.server.fastmcp.exceptions import ToolError from rich.console import Console from rich.table import Table @@ -50,6 +49,9 @@ def orphans( """ from basic_memory.cli.commands.command_utils import run_with_cleanup + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError + try: validate_routing_flags(local, cloud) with force_routing(local=local, cloud=cloud): diff --git a/src/basic_memory/cli/commands/schema.py b/src/basic_memory/cli/commands/schema.py index 8bb1d595..86f63e75 100644 --- a/src/basic_memory/cli/commands/schema.py +++ b/src/basic_memory/cli/commands/schema.py @@ -20,9 +20,10 @@ from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.cli.commands.routing import force_routing, validate_routing_flags from basic_memory.config import ConfigManager -from basic_memory.mcp.tools import schema_diff as mcp_schema_diff -from basic_memory.mcp.tools import schema_infer as mcp_schema_infer -from basic_memory.mcp.tools import schema_validate as mcp_schema_validate + +# MCP tool functions are imported inside each command: importing +# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK, +# SQLAlchemy), which would slow every CLI invocation, including --help (#886). console = Console() @@ -189,6 +190,9 @@ def validate( Use --local to force local routing when cloud mode is enabled. Use --cloud to force cloud routing when cloud mode is disabled. """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import schema_validate as mcp_schema_validate + try: validate_routing_flags(local, cloud) project_name = _resolve_project_name(project) @@ -272,6 +276,9 @@ def infer( Use --local to force local routing when cloud mode is enabled. Use --cloud to force cloud routing when cloud mode is disabled. """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import schema_infer as mcp_schema_infer + try: validate_routing_flags(local, cloud) project_name = _resolve_project_name(project) @@ -352,6 +359,9 @@ def diff( Use --local to force local routing when cloud mode is enabled. Use --cloud to force cloud routing when cloud mode is disabled. """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import schema_diff as mcp_schema_diff + try: validate_routing_flags(local, cloud) project_name = _resolve_project_name(project) diff --git a/src/basic_memory/cli/commands/status.py b/src/basic_memory/cli/commands/status.py index 773218d2..de63df71 100644 --- a/src/basic_memory/cli/commands/status.py +++ b/src/basic_memory/cli/commands/status.py @@ -5,7 +5,6 @@ import time from typing import Annotated, Dict, Optional, Set -from mcp.server.fastmcp.exceptions import ToolError import typer from loguru import logger from rich.console import Console @@ -231,6 +230,9 @@ def status( """ from basic_memory.cli.commands.command_utils import run_with_cleanup + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError + # Trigger: --wait with a negative --timeout # Why: a negative deadline times out on the very first poll, producing a confusing # "Timed out after -5s" message instead of flagging the bad input. Raised diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 3df836d9..210db273 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -14,18 +14,10 @@ from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.cli.commands.routing import force_routing, validate_routing_flags -from basic_memory.mcp.tools import build_context as mcp_build_context -from basic_memory.mcp.tools import delete_note as mcp_delete_note -from basic_memory.mcp.tools import edit_note as mcp_edit_note -from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects -from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces -from basic_memory.mcp.tools import read_note as mcp_read_note -from basic_memory.mcp.tools import recent_activity as mcp_recent_activity -from basic_memory.mcp.tools import schema_diff as mcp_schema_diff -from basic_memory.mcp.tools import schema_infer as mcp_schema_infer -from basic_memory.mcp.tools import schema_validate as mcp_schema_validate -from basic_memory.mcp.tools import search_notes as mcp_search -from basic_memory.mcp.tools import write_note as mcp_write_note + +# MCP tool functions are imported inside each command: importing +# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK, +# SQLAlchemy), which would slow every CLI invocation, including --help (#886). tool_app = typer.Typer() app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI") @@ -120,6 +112,9 @@ def write_note( bm tool write-note --title "My Note" --folder "notes" --overwrite bm tool write-note --title "My Note" --folder "notes" --local """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import write_note as mcp_write_note + try: validate_routing_flags(local, cloud) @@ -206,6 +201,9 @@ def read_note( bm tool read-note my-note bm tool read-note my-note --include-frontmatter """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import read_note as mcp_read_note + try: validate_routing_flags(local, cloud) @@ -272,6 +270,9 @@ def delete_note( bm tool delete-note notes/old-draft bm tool delete-note docs/archive --is-directory """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import delete_note as mcp_delete_note + try: validate_routing_flags(local, cloud) @@ -346,6 +347,9 @@ def edit_note( bm tool edit-note my-note --operation find_replace --find-text "old" --content "new" bm tool edit-note my-note --operation replace_section --section "## Notes" --content "updated" """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import edit_note as mcp_edit_note + try: validate_routing_flags(local, cloud) @@ -413,6 +417,9 @@ def build_context( bm tool build-context memory://specs/search bm tool build-context specs/search --depth 2 --timeframe 30d """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import build_context as mcp_build_context + try: validate_routing_flags(local, cloud) @@ -476,6 +483,9 @@ def recent_activity( bm tool recent-activity --timeframe 30d --page-size 20 bm tool recent-activity --type entity --type observation """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import recent_activity as mcp_recent_activity + try: validate_routing_flags(local, cloud) @@ -582,6 +592,9 @@ def search_notes( bm tool search-notes --meta status=draft bm tool search-notes "auth" --entity-type observation --category requirement """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import search_notes as mcp_search + try: validate_routing_flags(local, cloud) @@ -687,6 +700,9 @@ def list_projects( bm tool list-projects bm tool list-projects --local """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects + try: validate_routing_flags(local, cloud) @@ -720,6 +736,9 @@ def list_workspaces( bm tool list-workspaces bm tool list-workspaces --cloud """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces + try: validate_routing_flags(local, cloud) @@ -772,6 +791,9 @@ def schema_validate( bm tool schema-validate people/ada-lovelace.md bm tool schema-validate --project research """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import schema_validate as mcp_schema_validate + try: validate_routing_flags(local, cloud) @@ -840,6 +862,9 @@ def schema_infer( bm tool schema-infer meeting --threshold 0.5 bm tool schema-infer person --project research """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import schema_infer as mcp_schema_infer + try: validate_routing_flags(local, cloud) @@ -896,6 +921,9 @@ def schema_diff( bm tool schema-diff person bm tool schema-diff person --project research """ + # Deferred: loading the MCP tool stack at module import slows CLI startup (#886). + from basic_memory.mcp.tools import schema_diff as mcp_schema_diff + try: validate_routing_flags(local, cloud) diff --git a/src/basic_memory/mcp/async_client.py b/src/basic_memory/mcp/async_client.py index 5b959a5a..594f4158 100644 --- a/src/basic_memory/mcp/async_client.py +++ b/src/basic_memory/mcp/async_client.py @@ -5,7 +5,6 @@ from threading import RLock from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional -from fastapi import Depends, FastAPI, Request from httpx import ASGITransport, AsyncClient, Timeout from loguru import logger @@ -13,6 +12,9 @@ from basic_memory.config import ConfigManager, ProjectMode, has_cloud_credentials if TYPE_CHECKING: + # FastAPI is only needed when a request routes through the local ASGI + # transport; importing it at module level costs ~0.1s on every CLI start (#886). + from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"] @@ -28,8 +30,8 @@ class _PreparedLocalAsgiDatabase: _prepared_local_asgi_database_lock = RLock() -_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {} -_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {} +_prepared_local_asgi_database_prepare_locks: dict["FastAPI", Lock] = {} +_prepared_local_asgi_databases: dict["FastAPI", _PreparedLocalAsgiDatabase] = {} def _force_local_mode() -> bool: @@ -57,7 +59,7 @@ def _build_timeout() -> Timeout: ) -def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient: +def _build_asgi_client(app: "FastAPI", timeout: Timeout) -> AsyncClient: """Create a local ASGI client for an already-prepared FastAPI app.""" from basic_memory.workspace_context import workspace_permalink_headers @@ -71,7 +73,7 @@ def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient: ) -def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock: +def _get_prepared_local_asgi_database_prepare_lock(app: "FastAPI") -> Lock: """Get the async lock that serializes first-time DB preparation for an app.""" with _prepared_local_asgi_database_lock: prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app) @@ -82,8 +84,10 @@ def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock: @asynccontextmanager -async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]: +async def _resolve_local_asgi_database(app: "FastAPI") -> AsyncIterator[LocalDatabaseState]: """Resolve database state for a local ASGI request.""" + # Imported on first local-ASGI use so CLI startup never pays for FastAPI (#886). + from fastapi import Depends, Request from fastapi.dependencies.utils import get_dependant, solve_dependencies from basic_memory.deps import get_engine_factory @@ -127,7 +131,7 @@ async def resolve_database_state( yield await resolve_database_state(**solved.values) -def _retain_prepared_local_asgi_database(app: FastAPI) -> bool: +def _retain_prepared_local_asgi_database(app: "FastAPI") -> bool: """Retain an active local ASGI database preparation if one exists.""" with _prepared_local_asgi_database_lock: active = _prepared_local_asgi_databases.get(app) @@ -139,7 +143,7 @@ def _retain_prepared_local_asgi_database(app: FastAPI) -> bool: def _install_prepared_local_asgi_database( - app: FastAPI, + app: "FastAPI", database_state: LocalDatabaseState, dependency_context: AbstractAsyncContextManager[LocalDatabaseState], ) -> None: @@ -163,7 +167,7 @@ def _install_prepared_local_asgi_database( ) -def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None: +def _restore_local_asgi_state_attribute(app: "FastAPI", name: str, previous_value: object) -> None: """Restore a FastAPI app.state attribute captured before local ASGI preparation.""" if previous_value is _MISSING_STATE_VALUE: if hasattr(app.state, name): @@ -173,7 +177,7 @@ def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: def _release_prepared_local_asgi_database( - app: FastAPI, + app: "FastAPI", ) -> AbstractAsyncContextManager[LocalDatabaseState] | None: """Release local ASGI database state after a client context exits.""" with _prepared_local_asgi_database_lock: @@ -196,7 +200,7 @@ def _release_prepared_local_asgi_database( @asynccontextmanager -async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]: +async def _prepared_local_asgi_database(app: "FastAPI") -> AsyncIterator[None]: """Initialize local ASGI database state before the first request.""" prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app) async with prepare_lock: diff --git a/src/basic_memory/mcp/clients/directory.py b/src/basic_memory/mcp/clients/directory.py index 5444aa62..c7dee421 100644 --- a/src/basic_memory/mcp/clients/directory.py +++ b/src/basic_memory/mcp/clients/directory.py @@ -7,7 +7,9 @@ from httpx import AsyncClient -from basic_memory.mcp.tools.utils import call_get +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). class DirectoryClient: @@ -55,6 +57,8 @@ async def list( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_get + params: dict = { "dir_name": dir_name, "depth": depth, diff --git a/src/basic_memory/mcp/clients/knowledge.py b/src/basic_memory/mcp/clients/knowledge.py index 2fb3f5b1..93974a92 100644 --- a/src/basic_memory/mcp/clients/knowledge.py +++ b/src/basic_memory/mcp/clients/knowledge.py @@ -8,7 +8,10 @@ from httpx import AsyncClient import logfire -from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete + +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). from basic_memory.schemas.response import ( EntityResponse, DeleteEntitiesResponse, @@ -57,6 +60,8 @@ async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + with logfire.span( "mcp.client.knowledge.create_entity", client_name="knowledge", @@ -89,6 +94,8 @@ async def update_entity( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_put + with logfire.span( "mcp.client.knowledge.update_entity", client_name="knowledge", @@ -116,6 +123,8 @@ async def get_entity(self, entity_id: str) -> EntityResponse: Raises: ToolError: If the entity is not found or request fails """ + from basic_memory.mcp.tools.utils import call_get + with logfire.span( "mcp.client.knowledge.get_entity", client_name="knowledge", @@ -147,6 +156,8 @@ async def patch_entity( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_patch + with logfire.span( "mcp.client.knowledge.patch_entity", client_name="knowledge", @@ -174,6 +185,8 @@ async def delete_entity(self, entity_id: str) -> DeleteEntitiesResponse: Raises: ToolError: If the entity is not found or request fails """ + from basic_memory.mcp.tools.utils import call_delete + with logfire.span( "mcp.client.knowledge.delete_entity", client_name="knowledge", @@ -201,6 +214,8 @@ async def move_entity(self, entity_id: str, destination_path: str) -> EntityResp Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_put + with logfire.span( "mcp.client.knowledge.move_entity", client_name="knowledge", @@ -231,6 +246,8 @@ async def move_directory( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + with logfire.span( "mcp.client.knowledge.move_directory", client_name="knowledge", @@ -261,6 +278,8 @@ async def delete_directory(self, directory: str) -> DirectoryDeleteResult: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + with logfire.span( "mcp.client.knowledge.delete_directory", client_name="knowledge", @@ -290,6 +309,8 @@ async def sync_file(self, file_path: str) -> EntityResponse: Raises: ToolError: If the file does not exist on disk or indexing fails """ + from basic_memory.mcp.tools.utils import call_post + with logfire.span( "mcp.client.knowledge.sync_file", client_name="knowledge", @@ -309,6 +330,8 @@ async def sync_file(self, file_path: str) -> EntityResponse: async def get_orphans(self) -> list[GraphNode]: """Get entities that have no incoming or outgoing relations.""" + from basic_memory.mcp.tools.utils import call_get + with logfire.span( "mcp.client.knowledge.get_orphans", client_name="knowledge", @@ -338,6 +361,8 @@ async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: Raises: ToolError: If the identifier cannot be resolved """ + from basic_memory.mcp.tools.utils import call_post + with logfire.span( "mcp.client.knowledge.resolve_entity", client_name="knowledge", diff --git a/src/basic_memory/mcp/clients/memory.py b/src/basic_memory/mcp/clients/memory.py index 17a3b60f..e3a241fd 100644 --- a/src/basic_memory/mcp/clients/memory.py +++ b/src/basic_memory/mcp/clients/memory.py @@ -8,7 +8,10 @@ from httpx import AsyncClient import logfire -from basic_memory.mcp.tools.utils import call_get + +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). from basic_memory.schemas.memory import GraphContext @@ -63,6 +66,8 @@ async def build_context( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_get + params: dict = { "depth": depth, "page": page, @@ -113,6 +118,8 @@ async def recent( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_get + params: dict = { "timeframe": timeframe, "depth": depth, diff --git a/src/basic_memory/mcp/clients/project.py b/src/basic_memory/mcp/clients/project.py index a5c2ad57..fad4152d 100644 --- a/src/basic_memory/mcp/clients/project.py +++ b/src/basic_memory/mcp/clients/project.py @@ -7,13 +7,9 @@ from httpx import AsyncClient -from basic_memory.mcp.tools.utils import ( - call_delete, - call_get, - call_patch, - call_post, - call_put, -) +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse from basic_memory.schemas.v2 import ProjectResolveResponse @@ -53,6 +49,8 @@ async def list_projects(self) -> ProjectList: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_get + response = await call_get( self.http_client, "/v2/projects/", @@ -71,6 +69,8 @@ async def create_project(self, project_data: dict[str, Any]) -> ProjectStatusRes Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + response = await call_post( self.http_client, "/v2/projects/", @@ -93,6 +93,8 @@ async def delete_project( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_delete + url = f"/v2/projects/{project_external_id}" if delete_notes: url += "?delete_notes=true" @@ -114,6 +116,8 @@ async def resolve_project(self, identifier: str) -> ProjectResolveResponse: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + response = await call_post( self.http_client, "/v2/projects/resolve", @@ -133,6 +137,8 @@ async def set_default(self, project_external_id: str) -> ProjectStatusResponse: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_put + response = await call_put( self.http_client, f"/v2/projects/{project_external_id}/default", @@ -154,6 +160,8 @@ async def update_project( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_patch + response = await call_patch( self.http_client, f"/v2/projects/{project_external_id}", @@ -181,6 +189,8 @@ async def sync( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + url = f"/v2/projects/{project_external_id}/sync" params = [] if force_full: @@ -204,6 +214,8 @@ async def get_status(self, project_external_id: str) -> SyncReportResponse: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + response = await call_post( self.http_client, f"/v2/projects/{project_external_id}/status", @@ -222,6 +234,8 @@ async def get_info(self, project_external_id: str) -> ProjectInfoResponse: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_get + response = await call_get( self.http_client, f"/v2/projects/{project_external_id}/info", diff --git a/src/basic_memory/mcp/clients/resource.py b/src/basic_memory/mcp/clients/resource.py index 4813b610..79637b04 100644 --- a/src/basic_memory/mcp/clients/resource.py +++ b/src/basic_memory/mcp/clients/resource.py @@ -6,7 +6,9 @@ from httpx import AsyncClient, Response import logfire -from basic_memory.mcp.tools.utils import call_get +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). class ResourceClient: @@ -49,6 +51,8 @@ async def read(self, entity_id: str) -> Response: Raises: ToolError: If the resource is not found or request fails """ + from basic_memory.mcp.tools.utils import call_get + with logfire.span( "mcp.client.resource.read", client_name="resource", diff --git a/src/basic_memory/mcp/clients/schema.py b/src/basic_memory/mcp/clients/schema.py index 36f72aaa..43fa8350 100644 --- a/src/basic_memory/mcp/clients/schema.py +++ b/src/basic_memory/mcp/clients/schema.py @@ -5,7 +5,9 @@ from httpx import AsyncClient -from basic_memory.mcp.tools.utils import call_post, call_get +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). from basic_memory.schemas.schema import ( ValidationReport, InferenceReport, @@ -56,6 +58,8 @@ async def validate( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + params: dict[str, str] = {} if note_type: params["note_type"] = note_type @@ -87,6 +91,8 @@ async def infer( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + response = await call_post( self.http_client, f"{self._base_path}/infer", @@ -106,6 +112,8 @@ async def diff(self, note_type: str) -> DriftReport: Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_get + response = await call_get( self.http_client, f"{self._base_path}/diff/{note_type}", diff --git a/src/basic_memory/mcp/clients/search.py b/src/basic_memory/mcp/clients/search.py index 4419ff50..cf3de796 100644 --- a/src/basic_memory/mcp/clients/search.py +++ b/src/basic_memory/mcp/clients/search.py @@ -8,7 +8,10 @@ from httpx import AsyncClient import logfire -from basic_memory.mcp.tools.utils import call_post + +# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module +# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup, +# so each method defers the import to call time instead (#886). from basic_memory.schemas.search import SearchResponse @@ -57,6 +60,8 @@ async def search( Raises: ToolError: If the request fails """ + from basic_memory.mcp.tools.utils import call_post + with logfire.span( "mcp.client.search.search", client_name="search", diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index f47bd3ef..13ab3d4c 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -8,10 +8,24 @@ compatibility with existing MCP tools. """ +# PEP 563 lazy annotations keep `Context` usable in signatures without importing +# fastmcp at module load — the fastmcp/mcp stack costs ~0.5s of CLI startup (#886). +from __future__ import annotations + import asyncio from contextlib import asynccontextmanager, nullcontext +from typing import ( + TYPE_CHECKING, + AsyncIterator, + Awaitable, + Callable, + List, + Optional, + Sequence, + Tuple, + cast, +) from dataclasses import dataclass, field -from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast from uuid import UUID from httpx import AsyncClient @@ -19,8 +33,6 @@ HeaderTypes, ) from loguru import logger -from fastmcp import Context -from mcp.server.fastmcp.exceptions import ToolError import logfire from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials @@ -46,6 +58,9 @@ workspace_permalink_context, ) +if TYPE_CHECKING: + from fastmcp import Context + # --- Workspace provider injection --- # Mirrors the set_client_factory() pattern in async_client.py. # The cloud MCP server sets a provider that queries its own database directly, @@ -1332,6 +1347,9 @@ async def resolve_project_and_path( # Why: allow project-scoped memory URLs without requiring a separate project parameter # Outcome: attempt to resolve the prefix as a project and route to it if project_prefix: + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError + if cached_project and _project_matches_identifier(cached_project, project_prefix): resolved_project = await resolve_project_parameter(project_prefix, context=context) if resolved_project and generate_permalink(resolved_project) != generate_permalink( @@ -1558,6 +1576,9 @@ async def get_project_client( is_factory_mode, ) + # Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886). + from mcp.server.fastmcp.exceptions import ToolError + # When project_id (UUID) is provided, prefer it as the resolution identifier. # external_id is unambiguous across workspaces; project name can collide. project_identifier = project_id if project_id else project diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index c7158e77..00ad41db 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -19,7 +19,6 @@ from typing import List, Optional, Annotated, Dict from annotated_types import MinLen, MaxLen -from dateparser import parse from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field @@ -92,6 +91,10 @@ def parse_timeframe(timeframe: str) -> datetime: parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone) parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone) """ + # Deferred: dateparser costs ~0.13s to import; schemas load on every CLI + # start, but timeframe parsing only happens per request (#886). + from dateparser import parse + if timeframe.lower() == "today": # For "today", return 1 day ago to ensure we capture recent activity across timezones # This handles the case where client and server are in different timezones diff --git a/tests/cli/test_ci_commands.py b/tests/cli/test_ci_commands.py index dbc21fb7..96274449 100644 --- a/tests/cli/test_ci_commands.py +++ b/tests/cli/test_ci_commands.py @@ -219,8 +219,8 @@ def test_setup_does_not_partially_write_generated_files_when_target_exists( mock_seed.assert_not_awaited() -@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock) -@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock) async def test_seed_project_update_schemas_skips_existing_notes_by_default( mock_write: AsyncMock, mock_search: AsyncMock, @@ -235,8 +235,8 @@ async def test_seed_project_update_schemas_skips_existing_notes_by_default( mock_write.assert_not_awaited() -@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock) -@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock) async def test_seed_project_update_schemas_refreshes_existing_notes( mock_write: AsyncMock, mock_search: AsyncMock, @@ -327,8 +327,8 @@ def test_agent_schema_command_writes_schema(tmp_path: Path) -> None: assert schema["title"] == "AgentSynthesis" -@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock) -@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock) def test_publish_command_upserts_project_update_note( mock_write: AsyncMock, mock_search: AsyncMock, @@ -401,8 +401,8 @@ def test_publish_command_upserts_project_update_note( ) -@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock) -@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock) def test_publish_command_preserves_existing_note_path_for_idempotency_match( mock_write: AsyncMock, mock_search: AsyncMock, @@ -464,8 +464,8 @@ def test_publish_command_preserves_existing_note_path_for_idempotency_match( assert kwargs["directory"] == "custom/project-updates" -@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock) -@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock) +@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock) def test_publish_command_uses_project_id_without_workspace_qualifying_project( mock_write: AsyncMock, mock_search: AsyncMock, diff --git a/tests/cli/test_cli_exit.py b/tests/cli/test_cli_exit.py index a4e643df..c34d7ed5 100644 --- a/tests/cli/test_cli_exit.py +++ b/tests/cli/test_cli_exit.py @@ -86,6 +86,46 @@ def test_bm_version_does_not_import_heavy_modules(): ) +def test_bm_cli_import_does_not_load_heavy_stack(): + """Regression test (#886): registering all CLI commands must stay lightweight. + + Importing basic_memory.cli.main with a normal argv registers every command + module. None of them may pull FastAPI, the API app, SQLAlchemy/Alembic, the + MCP tool stack, or the markdown/services layers in at import time — those + must load lazily when a command actually runs. + """ + heavy_modules = ( + "fastapi", + "sqlalchemy", + "alembic", + "fastmcp", + "mcp", + "basic_memory.api.app", + "basic_memory.db", + "basic_memory.markdown", + "basic_memory.mcp.tools", + "basic_memory.services", + ) + check_script = ( + "import sys; " + "sys.argv = ['bm', 'tool', 'search-notes', '--help']; " + "import basic_memory.cli.main; " + f"heavy = [m for m in {heavy_modules!r} if m in sys.modules]; " + "print(','.join(heavy) if heavy else 'CLEAN')" + ) + result = subprocess.run( + ["uv", "run", "python", "-c", check_script], + capture_output=True, + text=True, + timeout=20, + cwd=Path(__file__).parent.parent.parent, + ) + assert result.returncode == 0 + assert "CLEAN" in result.stdout, ( + f"Heavy modules loaded during CLI import: {result.stdout.strip()}" + ) + + def test_bm_help_does_not_import_api_app(): """Regression test: 'bm --help' must not build the FastAPI app graph.""" check_script = ( diff --git a/tests/cli/test_cli_schema.py b/tests/cli/test_cli_schema.py index e928e5d8..e2de5af7 100644 --- a/tests/cli/test_cli_schema.py +++ b/tests/cli/test_cli_schema.py @@ -87,7 +87,7 @@ def _mock_config_manager(): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=VALIDATE_REPORT, ) @@ -108,7 +108,7 @@ def test_validate_renders_table(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=VALIDATE_REPORT, ) @@ -123,7 +123,7 @@ def test_validate_strict_exits_on_errors(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value={"error": "No notes found of type 'person'"}, ) @@ -139,7 +139,7 @@ def test_validate_error_response(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=VALIDATE_REPORT, ) @@ -159,7 +159,7 @@ def test_validate_identifier_heuristic(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value=INFER_REPORT, ) @@ -179,7 +179,7 @@ def test_infer_renders_table(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value=INFER_REPORT, ) @@ -195,7 +195,7 @@ def test_infer_threshold_passthrough(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value={"error": "No schema pattern found for 'person' (threshold: 25%)"}, ) @@ -211,7 +211,7 @@ def test_infer_error_response(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value={ "note_type": "person", @@ -238,7 +238,7 @@ def test_infer_zero_notes(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_diff", + "basic_memory.mcp.tools.schema_diff", new_callable=AsyncMock, return_value=DIFF_REPORT_WITH_DRIFT, ) @@ -259,7 +259,7 @@ def test_diff_renders_drift(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_diff", + "basic_memory.mcp.tools.schema_diff", new_callable=AsyncMock, return_value=DIFF_REPORT_NO_DRIFT, ) @@ -275,7 +275,7 @@ def test_diff_no_drift(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_diff", + "basic_memory.mcp.tools.schema_diff", new_callable=AsyncMock, return_value={"error": "No schema found for type 'person'"}, ) diff --git a/tests/cli/test_cli_tool_json_output.py b/tests/cli/test_cli_tool_json_output.py index 786c5bda..adf98887 100644 --- a/tests/cli/test_cli_tool_json_output.py +++ b/tests/cli/test_cli_tool_json_output.py @@ -98,7 +98,7 @@ @patch( - "basic_memory.cli.commands.tool.mcp_write_note", + "basic_memory.mcp.tools.write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) @@ -129,7 +129,7 @@ def test_write_note_json_output(mock_mcp_write): @patch( - "basic_memory.cli.commands.tool.mcp_write_note", + "basic_memory.mcp.tools.write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) @@ -161,7 +161,7 @@ def test_write_note_project_id_passthrough(mock_mcp_write): @patch( - "basic_memory.cli.commands.tool.mcp_write_note", + "basic_memory.mcp.tools.write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) @@ -190,7 +190,7 @@ def test_write_note_with_tags(mock_mcp_write): @patch( - "basic_memory.cli.commands.tool.mcp_write_note", + "basic_memory.mcp.tools.write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) @@ -217,7 +217,7 @@ def test_write_note_type_passthrough(mock_mcp_write): @patch( - "basic_memory.cli.commands.tool.mcp_write_note", + "basic_memory.mcp.tools.write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) @@ -245,7 +245,7 @@ def test_write_note_type_defaults_to_note(mock_mcp_write): @patch( - "basic_memory.cli.commands.tool.mcp_read_note", + "basic_memory.mcp.tools.read_note", new_callable=AsyncMock, return_value=READ_NOTE_RESULT, ) @@ -267,7 +267,7 @@ def test_read_note_json_output(mock_mcp_read): @patch( - "basic_memory.cli.commands.tool.mcp_read_note", + "basic_memory.mcp.tools.read_note", new_callable=AsyncMock, return_value=READ_NOTE_RESULT, ) @@ -286,7 +286,7 @@ def test_read_note_include_frontmatter(mock_mcp_read): @patch( - "basic_memory.cli.commands.tool.mcp_delete_note", + "basic_memory.mcp.tools.delete_note", new_callable=AsyncMock, return_value=DELETE_NOTE_RESULT, ) @@ -307,7 +307,7 @@ def test_delete_note_json_output(mock_mcp_delete: AsyncMock) -> None: @patch( - "basic_memory.cli.commands.tool.mcp_delete_note", + "basic_memory.mcp.tools.delete_note", new_callable=AsyncMock, return_value=DELETE_DIRECTORY_RESULT, ) @@ -325,7 +325,7 @@ def test_delete_note_directory_flag(mock_mcp_delete: AsyncMock) -> None: @patch( - "basic_memory.cli.commands.tool.mcp_delete_note", + "basic_memory.mcp.tools.delete_note", new_callable=AsyncMock, return_value={ "deleted": False, @@ -349,7 +349,7 @@ def test_delete_note_not_found_outputs_json(mock_mcp_delete: AsyncMock) -> None: @patch( - "basic_memory.cli.commands.tool.mcp_delete_note", + "basic_memory.mcp.tools.delete_note", new_callable=AsyncMock, return_value={ "deleted": False, @@ -371,7 +371,7 @@ def test_delete_note_error_response(mock_mcp_delete: AsyncMock) -> None: @patch( - "basic_memory.cli.commands.tool.mcp_delete_note", + "basic_memory.mcp.tools.delete_note", new_callable=AsyncMock, return_value={ "deleted": False, @@ -398,7 +398,7 @@ def test_delete_note_directory_partial_failure_exits_nonzero( @patch( - "basic_memory.cli.commands.tool.mcp_delete_note", + "basic_memory.mcp.tools.delete_note", new_callable=AsyncMock, return_value=DELETE_NOTE_RESULT, ) @@ -418,7 +418,7 @@ def test_delete_note_project_id_passthrough(mock_mcp_delete: AsyncMock) -> None: @patch( - "basic_memory.cli.commands.tool.mcp_edit_note", + "basic_memory.mcp.tools.edit_note", new_callable=AsyncMock, return_value=EDIT_NOTE_RESULT, ) @@ -446,7 +446,7 @@ def test_edit_note_json_output(mock_mcp_edit): @patch( - "basic_memory.cli.commands.tool.mcp_edit_note", + "basic_memory.mcp.tools.edit_note", new_callable=AsyncMock, return_value={"title": "Test", "permalink": "test", "error": "Edit failed: not found"}, ) @@ -472,7 +472,7 @@ def test_edit_note_error_response(mock_mcp_edit): @patch( - "basic_memory.cli.commands.tool.mcp_build_context", + "basic_memory.mcp.tools.build_context", new_callable=AsyncMock, return_value=BUILD_CONTEXT_RESULT, ) @@ -491,7 +491,7 @@ def test_build_context_json_output(mock_build_ctx): @patch( - "basic_memory.cli.commands.tool.mcp_build_context", + "basic_memory.mcp.tools.build_context", new_callable=AsyncMock, return_value=BUILD_CONTEXT_RESULT, ) @@ -526,7 +526,7 @@ def test_build_context_with_options(mock_build_ctx): @patch( - "basic_memory.cli.commands.tool.mcp_recent_activity", + "basic_memory.mcp.tools.recent_activity", new_callable=AsyncMock, return_value=RECENT_ACTIVITY_RESULT, ) @@ -548,7 +548,7 @@ def test_recent_activity_json_output(mock_mcp_recent): @patch( - "basic_memory.cli.commands.tool.mcp_recent_activity", + "basic_memory.mcp.tools.recent_activity", new_callable=AsyncMock, return_value=RECENT_ACTIVITY_RESULT, ) @@ -566,7 +566,7 @@ def test_recent_activity_pagination(mock_mcp_recent): @patch( - "basic_memory.cli.commands.tool.mcp_recent_activity", + "basic_memory.mcp.tools.recent_activity", new_callable=AsyncMock, return_value=[], ) @@ -586,7 +586,7 @@ def test_recent_activity_empty(mock_mcp_recent): @patch( - "basic_memory.cli.commands.tool.mcp_search", + "basic_memory.mcp.tools.search_notes", new_callable=AsyncMock, return_value=SEARCH_RESULT, ) @@ -606,7 +606,7 @@ def test_search_notes_json_output(mock_mcp_search): @patch( - "basic_memory.cli.commands.tool.mcp_search", + "basic_memory.mcp.tools.search_notes", new_callable=AsyncMock, return_value=SEARCH_RESULT, ) @@ -622,7 +622,7 @@ def test_search_notes_with_meta_filter(mock_mcp_search): @patch( - "basic_memory.cli.commands.tool.mcp_search", + "basic_memory.mcp.tools.search_notes", new_callable=AsyncMock, return_value=SEARCH_RESULT, ) @@ -638,7 +638,7 @@ def test_search_notes_permalink_mode(mock_mcp_search): @patch( - "basic_memory.cli.commands.tool.mcp_search", + "basic_memory.mcp.tools.search_notes", new_callable=AsyncMock, return_value="Error: search failed", ) @@ -699,7 +699,7 @@ def test_routing_both_flags_error(): @patch( - "basic_memory.cli.commands.tool.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=SCHEMA_VALIDATE_RESULT, ) @@ -719,7 +719,7 @@ def test_schema_validate_json_output(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=SCHEMA_VALIDATE_RESULT, ) @@ -736,7 +736,7 @@ def test_schema_validate_identifier_heuristic(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value={"error": "No notes found of type 'person'"}, ) @@ -769,7 +769,7 @@ def test_schema_validate_error_response(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value=SCHEMA_INFER_RESULT, ) @@ -789,7 +789,7 @@ def test_schema_infer_json_output(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value=SCHEMA_INFER_RESULT, ) @@ -818,7 +818,7 @@ def test_schema_infer_threshold_passthrough(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_schema_diff", + "basic_memory.mcp.tools.schema_diff", new_callable=AsyncMock, return_value=SCHEMA_DIFF_RESULT, ) @@ -860,7 +860,7 @@ def test_schema_diff_json_output(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_list_projects", + "basic_memory.mcp.tools.list_memory_projects", new_callable=AsyncMock, return_value=LIST_PROJECTS_RESULT, ) @@ -898,7 +898,7 @@ def test_list_projects_json_output(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_list_workspaces", + "basic_memory.mcp.tools.list_workspaces", new_callable=AsyncMock, return_value=LIST_WORKSPACES_RESULT, ) @@ -919,7 +919,7 @@ def test_list_workspaces_json_output(mock_mcp): @patch( - "basic_memory.cli.commands.tool.mcp_list_workspaces", + "basic_memory.mcp.tools.list_workspaces", new_callable=AsyncMock, return_value={"workspaces": [], "count": 0}, ) diff --git a/tests/cli/test_db_reindex.py b/tests/cli/test_db_reindex.py index 49cf2c69..b2c02317 100644 --- a/tests/cli/test_db_reindex.py +++ b/tests/cli/test_db_reindex.py @@ -195,15 +195,21 @@ def update(self, task_id, **kwargs): if "total" in kwargs: self.tasks[task_id].total = kwargs["total"] - monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock()) + # _reindex imports its database/sync dependencies at call time (#886), + # so stubs target the source modules instead of db_cmd attributes. monkeypatch.setattr( - db_cmd.db, - "get_or_create_db", + "basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock() + ) + monkeypatch.setattr( + "basic_memory.db.get_or_create_db", AsyncMock(return_value=(None, session_maker)), ) - monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock()) - monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository) - monkeypatch.setattr(db_cmd, "get_sync_service", AsyncMock(return_value=sync_service)) + monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock()) + monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository) + monkeypatch.setattr( + "basic_memory.sync.sync_service.get_sync_service", + AsyncMock(return_value=sync_service), + ) monkeypatch.setattr(db_cmd, "Progress", SilentProgress) monkeypatch.setattr( db_cmd.console, @@ -277,14 +283,17 @@ def update(self, task_id, **kwargs): if "total" in kwargs: self.tasks[task_id].total = kwargs["total"] - monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock()) + # _reindex imports its database/sync dependencies at call time (#886), + # so stubs target the source modules instead of db_cmd attributes. + monkeypatch.setattr( + "basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock() + ) monkeypatch.setattr( - db_cmd.db, - "get_or_create_db", + "basic_memory.db.get_or_create_db", AsyncMock(return_value=(None, session_maker)), ) - monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock()) - monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository) + monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock()) + monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository) monkeypatch.setattr( "basic_memory.repository.search_repository.create_search_repository", lambda *args, **kwargs: object(), diff --git a/tests/cli/test_json_output.py b/tests/cli/test_json_output.py index 5cc99b25..a73eae04 100644 --- a/tests/cli/test_json_output.py +++ b/tests/cli/test_json_output.py @@ -368,7 +368,7 @@ async def fake_get_client(project_name=None): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=VALIDATE_REPORT, ) @@ -387,7 +387,7 @@ def test_schema_validate_json(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value={"error": "No schema found for type 'person'"}, ) @@ -404,7 +404,7 @@ def test_schema_validate_json_error(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_validate", + "basic_memory.mcp.tools.schema_validate", new_callable=AsyncMock, return_value=VALIDATE_REPORT, ) @@ -427,7 +427,7 @@ def test_schema_validate_json_strict_exit(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_infer", + "basic_memory.mcp.tools.schema_infer", new_callable=AsyncMock, return_value=INFER_REPORT, ) @@ -451,7 +451,7 @@ def test_schema_infer_json(mock_mcp, mock_config_cls): @patch("basic_memory.cli.commands.schema.ConfigManager") @patch( - "basic_memory.cli.commands.schema.mcp_schema_diff", + "basic_memory.mcp.tools.schema_diff", new_callable=AsyncMock, return_value=DIFF_REPORT_WITH_DRIFT, ) diff --git a/tests/mcp/clients/test_clients.py b/tests/mcp/clients/test_clients.py index 95f96f10..1942c8cc 100644 --- a/tests/mcp/clients/test_clients.py +++ b/tests/mcp/clients/test_clients.py @@ -27,7 +27,6 @@ def test_init(self): @pytest.mark.asyncio async def test_create_entity(self, monkeypatch): """Test create_entity calls correct endpoint.""" - from basic_memory.mcp.clients import knowledge as knowledge_mod mock_response = MagicMock() mock_response.json.return_value = { @@ -47,7 +46,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs.get("params") is None return mock_response - monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) mock_http = MagicMock() client = KnowledgeClient(mock_http, "proj-123") @@ -57,7 +56,6 @@ async def mock_call_post(client, url, **kwargs): @pytest.mark.asyncio async def test_update_entity(self, monkeypatch): """Test update_entity calls correct endpoint without fast query params.""" - from basic_memory.mcp.clients import knowledge as knowledge_mod mock_response = MagicMock() mock_response.json.return_value = { @@ -77,7 +75,7 @@ async def mock_call_put(client, url, **kwargs): assert kwargs.get("params") is None return mock_response - monkeypatch.setattr(knowledge_mod, "call_put", mock_call_put) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_put", mock_call_put) mock_http = MagicMock() client = KnowledgeClient(mock_http, "proj-123") @@ -87,7 +85,6 @@ async def mock_call_put(client, url, **kwargs): @pytest.mark.asyncio async def test_patch_entity(self, monkeypatch): """Test patch_entity calls correct endpoint without fast query params.""" - from basic_memory.mcp.clients import knowledge as knowledge_mod mock_response = MagicMock() mock_response.json.return_value = { @@ -107,7 +104,7 @@ async def mock_call_patch(client, url, **kwargs): assert kwargs.get("params") is None return mock_response - monkeypatch.setattr(knowledge_mod, "call_patch", mock_call_patch) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_patch", mock_call_patch) mock_http = MagicMock() client = KnowledgeClient(mock_http, "proj-123") @@ -117,7 +114,6 @@ async def mock_call_patch(client, url, **kwargs): @pytest.mark.asyncio async def test_resolve_entity(self, monkeypatch): """Test resolve_entity returns external_id.""" - from basic_memory.mcp.clients import knowledge as knowledge_mod mock_response = MagicMock() mock_response.json.return_value = {"external_id": "entity-uuid-123"} @@ -126,7 +122,7 @@ async def mock_call_post(client, url, **kwargs): assert "/v2/projects/proj-123/knowledge/resolve" in url return mock_response - monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) mock_http = MagicMock() client = KnowledgeClient(mock_http, "proj-123") @@ -136,7 +132,6 @@ async def mock_call_post(client, url, **kwargs): @pytest.mark.asyncio async def test_sync_file(self, monkeypatch): """Test sync_file posts the file path to the sync-file endpoint.""" - from basic_memory.mcp.clients import knowledge as knowledge_mod mock_response = MagicMock() mock_response.json.return_value = { @@ -156,7 +151,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs.get("json") == {"file_path": "notes/disk-note.md"} return mock_response - monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) mock_http = MagicMock() client = KnowledgeClient(mock_http, "proj-123") @@ -166,7 +161,6 @@ async def mock_call_post(client, url, **kwargs): @pytest.mark.asyncio async def test_get_orphans_validates_response(self, monkeypatch): """Orphan responses are validated into GraphNode objects.""" - from basic_memory.mcp.clients import knowledge as knowledge_mod from basic_memory.schemas.v2.graph import GraphNode mock_response = MagicMock() @@ -186,7 +180,7 @@ async def mock_call_get(client, url, **kwargs): assert "/v2/projects/proj-123/knowledge/orphans" in url return mock_response - monkeypatch.setattr(knowledge_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = KnowledgeClient(mock_http, "proj-123") @@ -211,7 +205,6 @@ def test_init(self): @pytest.mark.asyncio async def test_search(self, monkeypatch): """Test search calls correct endpoint.""" - from basic_memory.mcp.clients import search as search_mod mock_response = MagicMock() mock_response.json.return_value = { @@ -225,7 +218,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs.get("params") == {"page": 1, "page_size": 10} return mock_response - monkeypatch.setattr(search_mod, "call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) mock_http = MagicMock() client = SearchClient(mock_http, "proj-123") @@ -248,7 +241,6 @@ def test_init(self): @pytest.mark.asyncio async def test_build_context(self, monkeypatch): """Test build_context calls correct endpoint.""" - from basic_memory.mcp.clients import memory as memory_mod from datetime import datetime mock_response = MagicMock() @@ -264,7 +256,7 @@ async def mock_call_get(client, url, **kwargs): assert "/v2/projects/proj-123/memory/specs/search" in url return mock_response - monkeypatch.setattr(memory_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = MemoryClient(mock_http, "proj-123") @@ -274,7 +266,6 @@ async def mock_call_get(client, url, **kwargs): @pytest.mark.asyncio async def test_recent(self, monkeypatch): """Test recent calls correct endpoint.""" - from basic_memory.mcp.clients import memory as memory_mod from datetime import datetime mock_response = MagicMock() @@ -293,7 +284,7 @@ async def mock_call_get(client, url, **kwargs): assert params.get("depth") == 2 return mock_response - monkeypatch.setattr(memory_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = MemoryClient(mock_http, "proj-123") @@ -304,7 +295,6 @@ async def mock_call_get(client, url, **kwargs): @pytest.mark.asyncio async def test_recent_with_types(self, monkeypatch): """Test recent with types filter.""" - from basic_memory.mcp.clients import memory as memory_mod from datetime import datetime mock_response = MagicMock() @@ -322,7 +312,7 @@ async def mock_call_get(client, url, **kwargs): assert params.get("type") == "note,spec" return mock_response - monkeypatch.setattr(memory_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = MemoryClient(mock_http, "proj-123") @@ -344,7 +334,6 @@ def test_init(self): @pytest.mark.asyncio async def test_list(self, monkeypatch): """Test list calls correct endpoint.""" - from basic_memory.mcp.clients import directory as directory_mod mock_response = MagicMock() mock_response.json.return_value = [{"name": "folder", "type": "directory"}] @@ -353,7 +342,7 @@ async def mock_call_get(client, url, **kwargs): assert "/v2/projects/proj-123/directory/list" in url return mock_response - monkeypatch.setattr(directory_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = DirectoryClient(mock_http, "proj-123") @@ -376,7 +365,6 @@ def test_init(self): @pytest.mark.asyncio async def test_read(self, monkeypatch): """Test read calls correct endpoint.""" - from basic_memory.mcp.clients import resource as resource_mod mock_response = MagicMock() mock_response.text = "# Note content" @@ -385,7 +373,7 @@ async def mock_call_get(client, url, **kwargs): assert "/v2/projects/proj-123/resource/entity-123" in url return mock_response - monkeypatch.setattr(resource_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = ResourceClient(mock_http, "proj-123") @@ -405,7 +393,6 @@ def test_init(self): @pytest.mark.asyncio async def test_list_projects(self, monkeypatch): """Test list_projects calls correct endpoint.""" - from basic_memory.mcp.clients import project as project_mod mock_response = MagicMock() mock_response.json.return_value = { @@ -425,7 +412,7 @@ async def mock_call_get(client, url, **kwargs): assert "/v2/projects" in url return mock_response - monkeypatch.setattr(project_mod, "call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) mock_http = MagicMock() client = ProjectClient(mock_http) diff --git a/tests/mcp/test_client_schema.py b/tests/mcp/test_client_schema.py index b6b85cdc..11cd4d8a 100644 --- a/tests/mcp/test_client_schema.py +++ b/tests/mcp/test_client_schema.py @@ -63,7 +63,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs.get("params") == {} return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) result = await schema_client.validate() assert isinstance(result, ValidationReport) @@ -88,7 +88,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs["params"]["note_type"] == "person" return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) result = await schema_client.validate(note_type="person") assert result.note_type == "person" @@ -113,7 +113,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs["params"]["identifier"] == "people/alice" return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) result = await schema_client.validate(identifier="people/alice") assert result.total_notes == 1 @@ -144,7 +144,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs["params"]["threshold"] == 0.25 return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) result = await schema_client.infer("person") assert isinstance(result, InferenceReport) @@ -171,7 +171,7 @@ async def mock_call_post(client, url, **kwargs): assert kwargs["params"]["threshold"] == 0.5 return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post) result = await schema_client.infer("meeting", threshold=0.5) assert result.note_type == "meeting" @@ -197,7 +197,7 @@ async def mock_call_get(client, url, **kwargs): assert url == "/v2/projects/test-project-id/schema/diff/person" return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) result = await schema_client.diff("person") assert isinstance(result, DriftReport) @@ -235,7 +235,7 @@ async def test_diff_with_drift(self, schema_client, monkeypatch): async def mock_call_get(client, url, **kwargs): return mock_response - monkeypatch.setattr("basic_memory.mcp.clients.schema.call_get", mock_call_get) + monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get) result = await schema_client.diff("person") assert len(result.new_fields) == 1