mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
1b39062ecd
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
"""
|
|
Basic Memory FastMCP server.
|
|
"""
|
|
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastmcp import FastMCP
|
|
from loguru import logger
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
|
|
|
from basic_memory import db
|
|
from basic_memory.cli.auth import CLIAuth
|
|
from basic_memory.db import scoped_session
|
|
from basic_memory.mcp.container import McpContainer, set_container
|
|
from basic_memory.services.initialization import initialize_app
|
|
import logfire
|
|
|
|
|
|
async def _log_embedding_status(session_maker: async_sessionmaker[AsyncSession]) -> None:
|
|
"""Log a clear summary of semantic embedding status at startup."""
|
|
try:
|
|
async with scoped_session(session_maker) as session:
|
|
entity_count = (
|
|
await session.execute(text("SELECT COUNT(*) FROM entity"))
|
|
).scalar() or 0
|
|
chunk_count = (
|
|
await session.execute(text("SELECT COUNT(*) FROM search_vector_chunks"))
|
|
).scalar() or 0
|
|
embedding_count = (
|
|
await session.execute(text("SELECT COUNT(*) FROM search_vector_embeddings_rowids"))
|
|
).scalar() or 0
|
|
|
|
if entity_count == 0:
|
|
logger.info("Semantic embeddings: no entities yet")
|
|
elif embedding_count == 0:
|
|
logger.warning(
|
|
f"Semantic embeddings: EMPTY — {entity_count} entities have no embeddings. "
|
|
"Run 'bm reindex --embeddings' to build them."
|
|
)
|
|
else:
|
|
logger.info(
|
|
f"Semantic embeddings: {embedding_count} embeddings "
|
|
f"across {chunk_count} chunks for {entity_count} entities"
|
|
)
|
|
except Exception as exc:
|
|
logger.debug(f"Could not check embedding status at startup: {exc}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastMCP):
|
|
"""Lifecycle manager for the MCP server.
|
|
|
|
Handles:
|
|
- Database initialization and migrations
|
|
- File sync via SyncCoordinator (if enabled and not in cloud mode)
|
|
- Proper cleanup on shutdown
|
|
"""
|
|
# --- Composition Root ---
|
|
# Create container and read config (single point of config access)
|
|
container = McpContainer.create()
|
|
set_container(container)
|
|
|
|
config = container.config
|
|
with logfire.span(
|
|
"mcp.lifecycle.startup",
|
|
entrypoint="mcp",
|
|
mode=container.mode.name.lower(),
|
|
default_project=config.default_project,
|
|
):
|
|
logger.info(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
|
logger.info(
|
|
f"Config: database_backend={config.database_backend.value}, "
|
|
f"semantic_search_enabled={config.semantic_search_enabled}, "
|
|
f"default_project={config.default_project}"
|
|
)
|
|
if config.semantic_search_enabled:
|
|
logger.info(
|
|
f"Semantic search: provider={config.semantic_embedding_provider}, "
|
|
f"model={config.semantic_embedding_model}, "
|
|
f"dimensions={config.semantic_embedding_dimensions or 'auto'}, "
|
|
f"batch_size={config.semantic_embedding_batch_size}"
|
|
)
|
|
|
|
# Log configured projects with their routing mode
|
|
for name, entry in config.projects.items():
|
|
default = " (default)" if name == config.default_project else ""
|
|
logger.info(f"Project: {name} -> {entry.path} [mode={entry.mode.value}]{default}")
|
|
|
|
# Check cloud auth status (local file check, no network call)
|
|
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
|
tokens = auth.load_tokens()
|
|
if tokens is not None:
|
|
if not auth.is_token_valid(tokens):
|
|
expires_at = tokens.get("expires_at", 0)
|
|
expired_ago = int(time.time() - expires_at)
|
|
logger.warning(
|
|
f"Cloud token expired {expired_ago}s ago - may need 'bm cloud login'"
|
|
)
|
|
else:
|
|
logger.info("Cloud: authenticated (OAuth token valid)")
|
|
|
|
if config.cloud_api_key:
|
|
logger.info("Cloud: API key configured")
|
|
|
|
# Track if we created the engine (vs test fixtures providing it)
|
|
# This prevents disposing an engine provided by test fixtures when
|
|
# multiple Client connections are made in the same test
|
|
engine_was_none = db._engine is None
|
|
|
|
# Initialize app (runs migrations, reconciles projects)
|
|
await initialize_app(container.config)
|
|
|
|
# Log embedding status so it's easy to spot in the logs
|
|
if config.semantic_search_enabled and db._session_maker is not None:
|
|
await _log_embedding_status(db._session_maker)
|
|
|
|
# Create and start sync coordinator (lifecycle centralized in coordinator)
|
|
sync_coordinator = container.create_sync_coordinator()
|
|
await sync_coordinator.start()
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
# Shutdown - coordinator handles clean task cancellation
|
|
with logfire.span(
|
|
"mcp.lifecycle.shutdown",
|
|
entrypoint="mcp",
|
|
mode=container.mode.name.lower(),
|
|
):
|
|
logger.debug("Shutting down Basic Memory MCP server")
|
|
|
|
await sync_coordinator.stop()
|
|
|
|
# Only shutdown DB if we created it (not if test fixture provided it)
|
|
if engine_was_none:
|
|
await db.shutdown_db()
|
|
logger.debug("Database connections closed")
|
|
else: # pragma: no cover
|
|
logger.debug("Skipping DB shutdown - engine provided externally")
|
|
|
|
|
|
mcp = FastMCP(
|
|
name="Basic Memory",
|
|
lifespan=lifespan,
|
|
)
|