refactor: async client context manager pattern for cloud consolidation (#344)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-10-09 19:09:47 -05:00
committed by GitHub
parent 53438d1eab
commit 8d2e70cfc8
44 changed files with 9644 additions and 1224 deletions
+2 -2
View File
@@ -244,7 +244,7 @@ class CLIAuth:
async def login(self) -> bool:
"""Perform OAuth Device Authorization login flow."""
console.print("[blue]Initiating WorkOS authentication...[/blue]")
console.print("[blue]Initiating authentication...[/blue]")
# Step 1: Request device authorization
device_response = await self.request_device_authorization()
@@ -265,7 +265,7 @@ class CLIAuth:
# Step 4: Save tokens
self.save_tokens(tokens)
console.print("\n[green]✅ Successfully authenticated with WorkOS![/green]")
console.print("\n[green]✅ Successfully authenticated with Basic Memory Cloud![/green]")
return True
def logout(self) -> None:
+11 -28
View File
@@ -7,8 +7,7 @@ import typer
from rich.console import Console
from basic_memory.cli.commands.cloud import get_authenticated_headers
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.mcp.project_context import get_active_project
@@ -21,40 +20,24 @@ async def run_sync(project: Optional[str] = None):
"""Run sync operation via API endpoint."""
try:
from basic_memory.config import ConfigManager
config = ConfigManager().config
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = await get_authenticated_headers()
project_item = await get_active_project(client, project, None, headers=auth_headers)
response = await call_post(
client, f"{project_item.project_url}/project/sync", headers=auth_headers
)
data = response.json()
console.print(f"[green]✓ {data['message']}[/green]")
async with get_client() as client:
project_item = await get_active_project(client, project, None)
response = await call_post(client, f"{project_item.project_url}/project/sync")
data = response.json()
console.print(f"[green]✓ {data['message']}[/green]")
except (ToolError, ValueError) as e:
console.print(f"[red]✗ Sync failed: {e}[/red]")
raise typer.Exit(1)
async def get_project_info(project: str):
"""Run sync operation via API endpoint."""
"""Get project information via API endpoint."""
try:
from basic_memory.config import ConfigManager
config = ConfigManager().config
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = await get_authenticated_headers()
project_item = await get_active_project(client, project, None, headers=auth_headers)
response = await call_get(
client, f"{project_item.project_url}/project/info", headers=auth_headers
)
return ProjectInfoResponse.model_validate(response.json())
async with get_client() as client:
project_item = await get_active_project(client, project, None)
response = await call_get(client, f"{project_item.project_url}/project/info")
return ProjectInfoResponse.model_validate(response.json())
except (ToolError, ValueError) as e:
console.print(f"[red]✗ Sync failed: {e}[/red]")
raise typer.Exit(1)
+62 -57
View File
@@ -20,70 +20,75 @@ from loguru import logger
import threading
from basic_memory.services.initialization import initialize_file_sync
config = ConfigManager().config
@app.command()
def mcp(
transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
host: str = typer.Option(
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
),
port: int = typer.Option(8000, help="Port for HTTP transports"),
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
): # pragma: no cover
"""Run the MCP server with configurable transport options.
if not config.cloud_mode_enabled:
This command starts an MCP server using one of three transport options:
@app.command()
def mcp(
transport: str = typer.Option(
"stdio", help="Transport type: stdio, streamable-http, or sse"
),
host: str = typer.Option(
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
),
port: int = typer.Option(8000, help="Port for HTTP transports"),
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
): # pragma: no cover
"""Run the MCP server with configurable transport options.
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients)
"""
This command starts an MCP server using one of three transport options:
# Validate and set project constraint if specified
if project:
config_manager = ConfigManager()
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
- stdio: Standard I/O (good for local usage)
- streamable-http: Recommended for web deployments (default)
- sse: Server-Sent Events (for compatibility with existing clients)
"""
# Set env var with validated project name
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}")
# Validate and set project constraint if specified
if project:
config_manager = ConfigManager()
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
app_config = ConfigManager().config
# Set env var with validated project name
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
logger.info(f"MCP server constrained to project: {project_name}")
def run_file_sync():
"""Run file sync in a separate thread with its own event loop."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(initialize_file_sync(app_config))
except Exception as e:
logger.error(f"File sync error: {e}", err=True)
finally:
loop.close()
app_config = ConfigManager().config
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
if app_config.sync_changes:
# Start the sync thread
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
sync_thread.start()
logger.info("Started file sync in background")
def run_file_sync():
"""Run file sync in a separate thread with its own event loop."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(initialize_file_sync(app_config))
except Exception as e:
logger.error(f"File sync error: {e}", err=True)
finally:
loop.close()
# Now run the MCP server (blocks)
logger.info(f"Starting MCP server with {transport.upper()} transport")
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
if app_config.sync_changes:
# Start the sync thread
sync_thread = threading.Thread(target=run_file_sync, daemon=True)
sync_thread.start()
logger.info("Started file sync in background")
if transport == "stdio":
mcp_server.run(
transport=transport,
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
transport=transport,
host=host,
port=port,
path=path,
log_level="INFO",
)
# Now run the MCP server (blocks)
logger.info(f"Starting MCP server with {transport.upper()} transport")
if transport == "stdio":
mcp_server.run(
transport=transport,
)
elif transport == "streamable-http" or transport == "sse":
mcp_server.run(
transport=transport,
host=host,
port=port,
path=path,
log_level="INFO",
)
+56 -51
View File
@@ -9,14 +9,13 @@ from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud import get_authenticated_headers
from basic_memory.cli.commands.command_utils import get_project_info
from basic_memory.config import ConfigManager
import json
from datetime import datetime
from rich.panel import Panel
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.project_info import ProjectList
from basic_memory.mcp.tools.utils import call_post
@@ -46,14 +45,14 @@ def format_path(path: str) -> str:
@project_app.command("list")
def list_projects() -> None:
"""List all Basic Memory projects."""
# Use API to list projects
try:
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = asyncio.run(get_authenticated_headers())
response = asyncio.run(call_get(client, "/projects/projects", headers=auth_headers))
result = ProjectList.model_validate(response.json())
async def _list_projects():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
return ProjectList.model_validate(response.json())
try:
result = asyncio.run(_list_projects())
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
@@ -79,16 +78,14 @@ if config.cloud_mode_enabled:
) -> None:
"""Add a new project to Basic Memory Cloud"""
async def _add_project():
async with get_client() as client:
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
response = await call_post(client, "/projects/projects", json=data)
return ProjectStatusResponse.model_validate(response.json())
try:
auth_headers = asyncio.run(get_authenticated_headers())
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
response = asyncio.run(
call_post(client, "/projects/projects", json=data, headers=auth_headers)
)
result = ProjectStatusResponse.model_validate(response.json())
result = asyncio.run(_add_project())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
@@ -109,12 +106,14 @@ else:
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
async def _add_project():
async with get_client() as client:
data = {"name": name, "path": resolved_path, "set_default": set_default}
response = await call_post(client, "/projects/projects", json=data)
return ProjectStatusResponse.model_validate(response.json())
try:
data = {"name": name, "path": resolved_path, "set_default": set_default}
response = asyncio.run(call_post(client, "/projects/projects", json=data))
result = ProjectStatusResponse.model_validate(response.json())
result = asyncio.run(_add_project())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
@@ -130,17 +129,15 @@ def remove_project(
name: str = typer.Argument(..., help="Name of the project to remove"),
) -> None:
"""Remove a project."""
async def _remove_project():
async with get_client() as client:
project_permalink = generate_permalink(name)
response = await call_delete(client, f"/projects/{project_permalink}")
return ProjectStatusResponse.model_validate(response.json())
try:
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = asyncio.run(get_authenticated_headers())
project_permalink = generate_permalink(name)
response = asyncio.run(
call_delete(client, f"/projects/{project_permalink}", headers=auth_headers)
)
result = ProjectStatusResponse.model_validate(response.json())
result = asyncio.run(_remove_project())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error removing project: {str(e)}[/red]")
@@ -157,11 +154,15 @@ if not config.cloud_mode_enabled:
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
) -> None:
"""Set the default project when 'config.default_project_mode' is set."""
try:
project_permalink = generate_permalink(name)
response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
result = ProjectStatusResponse.model_validate(response.json())
async def _set_default():
async with get_client() as client:
project_permalink = generate_permalink(name)
response = await call_put(client, f"/projects/{project_permalink}/default")
return ProjectStatusResponse.model_validate(response.json())
try:
result = asyncio.run(_set_default())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error setting default project: {str(e)}[/red]")
@@ -170,12 +171,14 @@ if not config.cloud_mode_enabled:
@project_app.command("sync-config")
def synchronize_projects() -> None:
"""Synchronize project config between configuration file and database."""
# Call the API to synchronize projects
async def _sync_config():
async with get_client() as client:
response = await call_post(client, "/projects/config/sync")
return ProjectStatusResponse.model_validate(response.json())
try:
response = asyncio.run(call_post(client, "/projects/config/sync"))
result = ProjectStatusResponse.model_validate(response.json())
result = asyncio.run(_sync_config())
console.print(f"[green]{result.message}[/green]")
except Exception as e: # pragma: no cover
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
@@ -190,17 +193,19 @@ if not config.cloud_mode_enabled:
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
async def _move_project():
async with get_client() as client:
data = {"path": resolved_path}
project_permalink = generate_permalink(name)
# TODO fix route to use ProjectPathDep
response = await call_patch(
client, f"/{name}/project/{project_permalink}", json=data
)
return ProjectStatusResponse.model_validate(response.json())
try:
data = {"path": resolved_path}
project_permalink = generate_permalink(name)
# TODO fix route to use ProjectPathDep
response = asyncio.run(
call_patch(client, f"/{name}/project/{project_permalink}", json=data)
)
result = ProjectStatusResponse.model_validate(response.json())
result = asyncio.run(_move_project())
console.print(f"[green]{result.message}[/green]")
# Show important file movement reminder
+10 -15
View File
@@ -12,8 +12,7 @@ from rich.panel import Panel
from rich.tree import Tree
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud import get_authenticated_headers
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas import SyncReportResponse
from basic_memory.mcp.project_context import get_active_project
@@ -129,21 +128,17 @@ def display_changes(
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
"""Check sync status of files vs database."""
from basic_memory.config import ConfigManager
try:
async with get_client() as client:
project_item = await get_active_project(client, project, None)
response = await call_post(client, f"{project_item.project_url}/project/status")
sync_report = SyncReportResponse.model_validate(response.json())
config = ConfigManager().config
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = await get_authenticated_headers()
project_item = await get_active_project(client, project, None, auth_headers)
response = await call_post(
client, f"{project_item.project_url}/project/status", headers=auth_headers
)
sync_report = SyncReportResponse.model_validate(response.json())
display_changes(project_item.name, "Status", sync_report, verbose)
display_changes(project_item.name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
console.print(f"[red]✗ Error: {e}[/red]")
raise typer.Exit(1)
@app.command()
-6
View File
@@ -109,12 +109,6 @@ class BasicMemoryConfig(BaseSettings):
description="If set, all projects must be created underneath this directory. Paths will be sanitized and constrained to this root. If not set, projects can be created anywhere (default behavior).",
)
# API connection configuration
api_url: Optional[str] = Field(
default=None,
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
)
# Cloud configuration
cloud_client_id: str = Field(
default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
+102 -4
View File
@@ -1,3 +1,6 @@
from contextlib import asynccontextmanager, AbstractAsyncContextManager
from typing import AsyncIterator, Callable, Optional
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
@@ -5,9 +8,108 @@ from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager
# Optional factory override for dependency injection
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
"""Override the default client factory (for cloud app, testing, etc).
Args:
factory: An async context manager that yields an AsyncClient
Example:
@asynccontextmanager
async def custom_client_factory():
async with AsyncClient(...) as client:
yield client
set_client_factory(custom_client_factory)
"""
global _client_factory
_client_factory = factory
@asynccontextmanager
async def get_client() -> AsyncIterator[AsyncClient]:
"""Get an AsyncClient as a context manager.
This function provides proper resource management for HTTP clients,
ensuring connections are closed after use. It supports three modes:
1. **Factory injection** (cloud app, tests):
If a custom factory is set via set_client_factory(), use that.
2. **CLI cloud mode**:
When cloud_mode_enabled is True, create HTTP client with auth
token from CLIAuth for requests to cloud proxy endpoint.
3. **Local mode** (default):
Use ASGI transport for in-process requests to local FastAPI app.
Usage:
async with get_client() as client:
response = await client.get("/path")
Yields:
AsyncClient: Configured HTTP client for the current mode
Raises:
RuntimeError: If cloud mode is enabled but user is not authenticated
"""
if _client_factory:
# Use injected factory (cloud app, tests)
async with _client_factory() as client:
yield client
else:
# Default: create based on config
config = ConfigManager().config
timeout = Timeout(
connect=10.0, # 10 seconds for connection
read=30.0, # 30 seconds for reading response
write=30.0, # 30 seconds for writing request
pool=30.0, # 30 seconds for connection pool
)
if config.cloud_mode_enabled:
# CLI cloud mode: inject auth when creating client
from basic_memory.cli.auth import CLIAuth
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
token = await auth.get_valid_token()
if not token:
raise RuntimeError(
"Cloud mode enabled but not authenticated. "
"Run 'basic-memory cloud login' first."
)
# Auth header set ONCE at client creation
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
async with AsyncClient(
base_url=proxy_base_url,
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
) as client:
yield client
else:
# Local mode: ASGI transport for in-process calls
logger.info("Creating ASGI client for local Basic Memory API")
async with AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
) as client:
yield client
def create_client() -> AsyncClient:
"""Create an HTTP client based on configuration.
DEPRECATED: Use get_client() context manager instead for proper resource management.
This function is kept for backward compatibility but will be removed in a future version.
The returned client should be closed manually by calling await client.aclose().
Returns:
AsyncClient configured for either local ASGI or remote proxy
"""
@@ -34,7 +136,3 @@ def create_client() -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
# Create shared async client
client = create_client()
@@ -10,7 +10,7 @@ from loguru import logger
from pydantic import Field
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.base import TimeFrame
@@ -42,20 +42,21 @@ async def continue_conversation(
"""
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
# Create request model
request = ContinueConversationRequest( # pyright: ignore [reportCallIssue]
topic=topic, timeframe=timeframe
)
async with get_client() as client:
# Create request model
request = ContinueConversationRequest( # pyright: ignore [reportCallIssue]
topic=topic, timeframe=timeframe
)
project_url = get_project_config().project_url
project_url = get_project_config().project_url
# Call the prompt API endpoint
response = await call_post(
client,
f"{project_url}/prompt/continue-conversation",
json=request.model_dump(exclude_none=True),
)
# Call the prompt API endpoint
response = await call_post(
client,
f"{project_url}/prompt/continue-conversation",
json=request.model_dump(exclude_none=True),
)
# Extract the rendered prompt from the response
result = response.json()
return result["prompt"]
# Extract the rendered prompt from the response
result = response.json()
return result["prompt"]
+12 -11
View File
@@ -9,7 +9,7 @@ from loguru import logger
from pydantic import Field
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.base import TimeFrame
@@ -41,16 +41,17 @@ async def search_prompt(
"""
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
# Create request model
request = SearchPromptRequest(query=query, timeframe=timeframe)
async with get_client() as client:
# Create request model
request = SearchPromptRequest(query=query, timeframe=timeframe)
project_url = get_project_config().project_url
project_url = get_project_config().project_url
# Call the prompt API endpoint
response = await call_post(
client, f"{project_url}/prompt/search", json=request.model_dump(exclude_none=True)
)
# Call the prompt API endpoint
response = await call_post(
client, f"{project_url}/prompt/search", json=request.model_dump(exclude_none=True)
)
# Extract the rendered prompt from the response
result = response.json()
return result["prompt"]
# Extract the rendered prompt from the response
result = response.json()
return result["prompt"]
@@ -5,7 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
@@ -59,11 +59,13 @@ async def project_info(
print(f"Basic Memory version: {info.system.version}")
"""
logger.info("Getting project info")
project_config = await get_active_project(client, project, context)
project_url = project_config.permalink
# Call the API endpoint
response = await call_get(client, f"{project_url}/project/info")
async with get_client() as client:
project_config = await get_active_project(client, project, context)
project_url = project_config.permalink
# Convert response to ProjectInfoResponse
return ProjectInfoResponse.model_validate(response.json())
# Call the API endpoint
response = await call_get(client, f"{project_url}/project/info")
# Convert response to ProjectInfoResponse
return ProjectInfoResponse.model_validate(response.json())
+37 -36
View File
@@ -5,7 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
@@ -102,42 +102,43 @@ async def build_context(
# URL is already validated and normalized by MemoryUrl type annotation
# Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context)
async with get_client() as client:
# Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context)
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
# Return a proper GraphContext with status message
from basic_memory.schemas.memory import MemoryMetadata
from datetime import datetime
return GraphContext(
results=[],
metadata=MemoryMetadata(
depth=depth or 1,
timeframe=timeframe,
generated_at=datetime.now().astimezone(),
primary_count=0,
related_count=0,
uri=migration_status, # Include status in metadata
),
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
project_url = active_project.project_url
if migration_status: # pragma: no cover
# Return a proper GraphContext with status message
from basic_memory.schemas.memory import MemoryMetadata
from datetime import datetime
response = await call_get(
client,
f"{project_url}/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
},
)
return GraphContext.model_validate(response.json())
return GraphContext(
results=[],
metadata=MemoryMetadata(
depth=depth or 1,
timeframe=timeframe,
generated_at=datetime.now().astimezone(),
primary_count=0,
related_count=0,
uri=migration_status, # Include status in metadata
),
)
project_url = active_project.project_url
response = await call_get(
client,
f"{project_url}/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
},
)
return GraphContext.model_validate(response.json())
+21 -20
View File
@@ -9,7 +9,7 @@ from typing import Dict, List, Any, Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
@@ -94,29 +94,30 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or folder path is invalid
"""
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}
# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)
# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)
# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path} in project {project}")
response = await call_put(client, f"{project_url}/resource/{file_path}", json=canvas_json)
# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path} in project {project}")
response = await call_put(client, f"{project_url}/resource/{file_path}", json=canvas_json)
# Parse response
result = response.json()
logger.debug(result)
# Parse response
result = response.json()
logger.debug(result)
# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
return "\n".join(summary)
return "\n".join(summary)
+19 -18
View File
@@ -7,7 +7,7 @@ from fastmcp import Context
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.schemas import DeleteEntitiesResponse
@@ -202,23 +202,24 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
try:
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
try:
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
if result.deleted:
logger.info(
f"Successfully deleted note: {identifier} in project: {active_project.name}"
)
return True
else:
logger.warning(f"Delete operation completed but note was not deleted: {identifier}")
return False
if result.deleted:
logger.info(
f"Successfully deleted note: {identifier} in project: {active_project.name}"
)
return True
else:
logger.warning(f"Delete operation completed but note was not deleted: {identifier}")
return False
except Exception as e: # pragma: no cover
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
# Return formatted error message for better user experience
return _format_delete_error_response(active_project.name, str(e), identifier)
except Exception as e: # pragma: no cover
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
# Return formatted error message for better user experience
return _format_delete_error_response(active_project.name, str(e), identifier)
+90 -89
View File
@@ -5,7 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_patch
@@ -214,106 +214,107 @@ async def edit_note(
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
# Validate operation
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
if operation not in valid_operations:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
)
# Validate operation
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
if operation not in valid_operations:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
)
# Validate required parameters for specific operations
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
# Validate required parameters for specific operations
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
# Use the PATCH endpoint to edit the entity
try:
# Prepare the edit request data
edit_data = {
"operation": operation,
"content": content,
}
# Use the PATCH endpoint to edit the entity
try:
# Prepare the edit request data
edit_data = {
"operation": operation,
"content": content,
}
# Add optional parameters
if section:
edit_data["section"] = section
if find_text:
edit_data["find_text"] = find_text
if expected_replacements != 1: # Only send if different from default
edit_data["expected_replacements"] = str(expected_replacements)
# Add optional parameters
if section:
edit_data["section"] = section
if find_text:
edit_data["find_text"] = find_text
if expected_replacements != 1: # Only send if different from default
edit_data["expected_replacements"] = str(expected_replacements)
# Call the PATCH endpoint
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_patch(client, url, json=edit_data)
result = EntityResponse.model_validate(response.json())
# Call the PATCH endpoint
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_patch(client, url, json=edit_data)
result = EntityResponse.model_validate(response.json())
# Format summary
summary = [
f"# Edited note ({operation})",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
# Format summary
summary = [
f"# Edited note ({operation})",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
# Add operation-specific details
if operation == "append":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to end of note")
elif operation == "prepend":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to beginning of note")
elif operation == "find_replace":
# For find_replace, we can't easily count replacements from here
# since we don't have the original content, but the server handled it
summary.append("operation: Find and replace operation completed")
elif operation == "replace_section":
summary.append(f"operation: Replaced content under section '{section}'")
# Add operation-specific details
if operation == "append":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to end of note")
elif operation == "prepend":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to beginning of note")
elif operation == "find_replace":
# For find_replace, we can't easily count replacements from here
# since we don't have the original content, but the server handled it
summary.append("operation: Find and replace operation completed")
elif operation == "replace_section":
summary.append(f"operation: Replaced content under section '{section}'")
# Count observations by category (reuse logic from write_note)
categories = {}
if result.observations:
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
# Count observations by category (reuse logic from write_note)
categories = {}
if result.observations:
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
summary.append("\\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
summary.append("\\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
logger.info(
"MCP tool response",
tool="edit_note",
operation=operation,
project=active_project.name,
permalink=result.permalink,
observations_count=len(result.observations),
relations_count=len(result.relations),
status_code=response.status_code,
)
logger.info(
"MCP tool response",
tool="edit_note",
operation=operation,
project=active_project.name,
permalink=result.permalink,
observations_count=len(result.observations),
relations_count=len(result.relations),
status_code=response.status_code,
)
result = "\n".join(summary)
return add_project_metadata(result, active_project.name)
result = "\n".join(summary)
return add_project_metadata(result, active_project.name)
except Exception as e:
logger.error(f"Error editing note: {e}")
return _format_error_response(
str(e), operation, identifier, find_text, expected_replacements, active_project.name
)
except Exception as e:
logger.error(f"Error editing note: {e}")
return _format_error_response(
str(e), operation, identifier, find_text, expected_replacements, active_project.name
)
-44
View File
@@ -1,44 +0,0 @@
from httpx._types import (
HeaderTypes,
)
from loguru import logger
from fastmcp.server.dependencies import get_http_headers
def inject_auth_header(headers: HeaderTypes | None = None) -> HeaderTypes:
"""
Inject JWT token from FastMCP context into headers if available.
Args:
headers: Existing headers dict or None
Returns:
Headers dict with Authorization header added if JWT is available
"""
# Start with existing headers or empty dict
if headers is None:
headers = {}
elif not isinstance(headers, dict):
# Convert other header types to dict
headers = dict(headers) # type: ignore
else:
# Make a copy to avoid modifying the original
headers = headers.copy()
http_headers = get_http_headers()
# Log only non-sensitive header keys for debugging
if logger.opt(lazy=True).debug:
sensitive_headers = {"authorization", "cookie", "x-api-key", "x-auth-token", "api-key"}
safe_headers = {k for k in http_headers.keys() if k.lower() not in sensitive_headers}
logger.debug(f"HTTP headers present: {list(safe_headers)}")
authorization = http_headers.get("Authorization") or http_headers.get("authorization")
if authorization:
headers["Authorization"] = authorization # type: ignore
# Log only that auth was injected, not the token value
logger.debug("Injected authorization header into request")
else:
logger.debug("No authorization header found in request")
return headers
+86 -83
View File
@@ -5,7 +5,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
@@ -63,102 +63,105 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
# Prepare query parameters
params = {
"dir_name": dir_name,
"depth": str(depth),
}
if file_name_glob:
params["file_name_glob"] = file_name_glob
logger.debug(
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
)
# Call the API endpoint
response = await call_get(
client,
f"{project_url}/directory/list",
params=params,
)
nodes = response.json()
if not nodes:
filter_desc = ""
# Prepare query parameters
params = {
"dir_name": dir_name,
"depth": str(depth),
}
if file_name_glob:
filter_desc = f" matching '{file_name_glob}'"
return f"No files found in directory '{dir_name}'{filter_desc}"
params["file_name_glob"] = file_name_glob
# Format the results
output_lines = []
if file_name_glob:
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
else:
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
output_lines.append("")
logger.debug(
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
)
# Group by type and sort
directories = [n for n in nodes if n["type"] == "directory"]
files = [n for n in nodes if n["type"] == "file"]
# Call the API endpoint
response = await call_get(
client,
f"{project_url}/directory/list",
params=params,
)
# Sort by name
directories.sort(key=lambda x: x["name"])
files.sort(key=lambda x: x["name"])
nodes = response.json()
# Display directories first
for node in directories:
path_display = node["directory_path"]
output_lines.append(f"📁 {node['name']:<30} {path_display}")
if not nodes:
filter_desc = ""
if file_name_glob:
filter_desc = f" matching '{file_name_glob}'"
return f"No files found in directory '{dir_name}'{filter_desc}"
# Add separator if we have both directories and files
if directories and files:
# Format the results
output_lines = []
if file_name_glob:
output_lines.append(
f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):"
)
else:
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
output_lines.append("")
# Display files with metadata
for node in files:
path_display = node["directory_path"]
title = node.get("title", "")
updated = node.get("updated_at", "")
# Group by type and sort
directories = [n for n in nodes if n["type"] == "directory"]
files = [n for n in nodes if n["type"] == "file"]
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
if path_display.startswith("/"):
path_display = path_display[1:]
# Sort by name
directories.sort(key=lambda x: x["name"])
files.sort(key=lambda x: x["name"])
# Format date if available
date_str = ""
if updated:
try:
from datetime import datetime
# Display directories first
for node in directories:
path_display = node["directory_path"]
output_lines.append(f"📁 {node['name']:<30} {path_display}")
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except Exception: # pragma: no cover
date_str = updated[:10] if len(updated) >= 10 else ""
# Add separator if we have both directories and files
if directories and files:
output_lines.append("")
# Create formatted line
file_line = f"📄 {node['name']:<30} {path_display}"
if title and title != node["name"]:
file_line += f" | {title}"
if date_str:
file_line += f" | {date_str}"
# Display files with metadata
for node in files:
path_display = node["directory_path"]
title = node.get("title", "")
updated = node.get("updated_at", "")
output_lines.append(file_line)
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
if path_display.startswith("/"):
path_display = path_display[1:]
# Add summary
output_lines.append("")
total_count = len(directories) + len(files)
summary_parts = []
if directories:
summary_parts.append(
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
)
if files:
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
# Format date if available
date_str = ""
if updated:
try:
from datetime import datetime
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except Exception: # pragma: no cover
date_str = updated[:10] if len(updated) >= 10 else ""
return "\n".join(output_lines)
# Create formatted line
file_line = f"📄 {node['name']:<30} {path_display}"
if title and title != node["name"]:
file_line += f" | {title}"
if date_str:
file_line += f" | {date_str}"
output_lines.append(file_line)
# Add summary
output_lines.append("")
total_count = len(directories) + len(files)
summary_parts = []
if directories:
summary_parts.append(
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
)
if files:
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
return "\n".join(output_lines)
+121 -119
View File
@@ -6,7 +6,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.mcp.project_context import get_active_project
@@ -16,11 +16,12 @@ from basic_memory.utils import validate_project_path
async def _detect_cross_project_move_attempt(
identifier: str, destination_path: str, current_project: str
client, identifier: str, destination_path: str, current_project: str
) -> Optional[str]:
"""Detect potential cross-project move attempts and return guidance.
Args:
client: The AsyncClient instance
identifier: The note identifier being moved
destination_path: The destination path
current_project: The current active project
@@ -394,20 +395,21 @@ async def move_note(
- Re-indexes the entity for search
- Maintains all observations and relations
"""
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
async with get_client() as client:
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
# Validate destination path to prevent path traversal attacks
project_path = active_project.home
if not validate_project_path(destination_path, project_path):
logger.warning(
"Attempted path traversal attack blocked",
destination_path=destination_path,
project=active_project.name,
)
return f"""# Move Failed - Security Validation Error
# Validate destination path to prevent path traversal attacks
project_path = active_project.home
if not validate_project_path(destination_path, project_path):
logger.warning(
"Attempted path traversal attack blocked",
destination_path=destination_path,
project=active_project.name,
)
return f"""# Move Failed - Security Validation Error
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
@@ -421,123 +423,123 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
```"""
# Check for potential cross-project move attempts
cross_project_error = await _detect_cross_project_move_attempt(
identifier, destination_path, active_project.name
)
if cross_project_error:
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
return cross_project_error
# Get the source entity information for extension validation
source_ext = "md" # Default to .md if we can't determine source extension
try:
# Fetch source entity information to get the current file extension
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_get(client, url)
source_entity = EntityResponse.model_validate(response.json())
if "." in source_entity.file_path:
source_ext = source_entity.file_path.split(".")[-1]
except Exception as e:
# If we can't fetch the source entity, default to .md extension
logger.debug(f"Could not fetch source entity for extension check: {e}")
# Validate that destination path includes a file extension
if "." not in destination_path or not destination_path.split(".")[-1]:
logger.warning(f"Move failed - no file extension provided: {destination_path}")
return dedent(f"""
# Move Failed - File Extension Required
The destination path '{destination_path}' must include a file extension (e.g., '.md').
## Valid examples:
- `notes/my-note.md`
- `projects/meeting-2025.txt`
- `archive/old-program.sh`
## Try again with extension:
```
move_note("{identifier}", "{destination_path}.{source_ext}")
```
All examples in Basic Memory expect file extensions to be explicitly provided.
""").strip()
# Get the source entity to check its file extension
try:
# Fetch source entity information
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_get(client, url)
source_entity = EntityResponse.model_validate(response.json())
# Extract file extensions
source_ext = (
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
# Check for potential cross-project move attempts
cross_project_error = await _detect_cross_project_move_attempt(
client, identifier, destination_path, active_project.name
)
dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
if cross_project_error:
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
return cross_project_error
# Check if extensions match
if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
logger.warning(
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
)
# Get the source entity information for extension validation
source_ext = "md" # Default to .md if we can't determine source extension
try:
# Fetch source entity information to get the current file extension
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_get(client, url)
source_entity = EntityResponse.model_validate(response.json())
if "." in source_entity.file_path:
source_ext = source_entity.file_path.split(".")[-1]
except Exception as e:
# If we can't fetch the source entity, default to .md extension
logger.debug(f"Could not fetch source entity for extension check: {e}")
# Validate that destination path includes a file extension
if "." not in destination_path or not destination_path.split(".")[-1]:
logger.warning(f"Move failed - no file extension provided: {destination_path}")
return dedent(f"""
# Move Failed - File Extension Mismatch
# Move Failed - File Extension Required
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
The destination path '{destination_path}' must include a file extension (e.g., '.md').
To preserve file type consistency, the destination must have the same extension as the source.
## Valid examples:
- `notes/my-note.md`
- `projects/meeting-2025.txt`
- `archive/old-program.sh`
## Source file:
- Path: `{source_entity.file_path}`
- Extension: `.{source_ext}`
## Try again with matching extension:
## Try again with extension:
```
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
move_note("{identifier}", "{destination_path}.{source_ext}")
```
All examples in Basic Memory expect file extensions to be explicitly provided.
""").strip()
except Exception as e:
# If we can't fetch the source entity, log it but continue
# This might happen if the identifier is not yet resolved
logger.debug(f"Could not fetch source entity for extension check: {e}")
try:
# Prepare move request
move_data = {
"identifier": identifier,
"destination_path": destination_path,
"project": active_project.name,
}
# Get the source entity to check its file extension
try:
# Fetch source entity information
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_get(client, url)
source_entity = EntityResponse.model_validate(response.json())
# Call the move API endpoint
url = f"{project_url}/knowledge/move"
response = await call_post(client, url, json=move_data)
result = EntityResponse.model_validate(response.json())
# Extract file extensions
source_ext = (
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
)
dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
# Build success message
result_lines = [
"✅ Note moved successfully",
"",
f"📁 **{identifier}** → **{result.file_path}**",
f"🔗 Permalink: {result.permalink}",
"📊 Database and search index updated",
"",
f"<!-- Project: {active_project.name} -->",
]
# Check if extensions match
if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
logger.warning(
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
)
return dedent(f"""
# Move Failed - File Extension Mismatch
# Log the operation
logger.info(
"Move note completed",
identifier=identifier,
destination_path=destination_path,
project=active_project.name,
status_code=response.status_code,
)
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
return "\n".join(result_lines)
To preserve file type consistency, the destination must have the same extension as the source.
except Exception as e:
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
# Return formatted error message for better user experience
return _format_move_error_response(str(e), identifier, destination_path)
## Source file:
- Path: `{source_entity.file_path}`
- Extension: `.{source_ext}`
## Try again with matching extension:
```
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
```
""").strip()
except Exception as e:
# If we can't fetch the source entity, log it but continue
# This might happen if the identifier is not yet resolved
logger.debug(f"Could not fetch source entity for extension check: {e}")
try:
# Prepare move request
move_data = {
"identifier": identifier,
"destination_path": destination_path,
"project": active_project.name,
}
# Call the move API endpoint
url = f"{project_url}/knowledge/move"
response = await call_post(client, url, json=move_data)
result = EntityResponse.model_validate(response.json())
# Build success message
result_lines = [
"✅ Note moved successfully",
"",
f"📁 **{identifier}** → **{result.file_path}**",
f"🔗 Permalink: {result.permalink}",
"📊 Database and search index updated",
"",
f"<!-- Project: {active_project.name} -->",
]
# Log the operation
logger.info(
"Move note completed",
identifier=identifier,
destination_path=destination_path,
project=active_project.name,
status_code=response.status_code,
)
return "\n".join(result_lines)
except Exception as e:
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
# Return formatted error message for better user experience
return _format_move_error_response(str(e), identifier, destination_path)
@@ -7,7 +7,7 @@ and manage project context during conversations.
import os
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
from basic_memory.schemas.project_info import (
@@ -40,34 +40,35 @@ async def list_memory_projects(context: Context | None = None) -> str:
Example:
list_memory_projects()
"""
if context: # pragma: no cover
await context.info("Listing all available projects")
async with get_client() as client:
if context: # pragma: no cover
await context.info("Listing all available projects")
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
# Get projects from API
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Get projects from API
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
if constrained_project:
result = f"Project: {constrained_project}\n\n"
result += "Note: This MCP server is constrained to a single project.\n"
result += "All operations will automatically use this project."
else:
# Show all projects with session guidance
result = "Available projects:\n"
if constrained_project:
result = f"Project: {constrained_project}\n\n"
result += "Note: This MCP server is constrained to a single project.\n"
result += "All operations will automatically use this project."
else:
# Show all projects with session guidance
result = "Available projects:\n"
for project in project_list.projects:
result += f"{project.name}\n"
for project in project_list.projects:
result += f"{project.name}\n"
result += "\n" + "" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
result += "Example: 'Which project should I use for this task?'\n\n"
result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
result += "The user can say 'switch to [project]' to change projects."
result += "\n" + "" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
result += "Example: 'Which project should I use for this task?'\n\n"
result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
result += "The user can say 'switch to [project]' to change projects."
return result
return result
@mcp.tool("create_memory_project")
@@ -91,37 +92,38 @@ async def create_memory_project(
create_memory_project("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True)
"""
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
if context: # pragma: no cover
await context.info(f"Creating project: {project_name} at {project_path}")
if context: # pragma: no cover
await context.info(f"Creating project: {project_name} at {project_path}")
# Create the project request
project_request = ProjectInfoRequest(
name=project_name, path=project_path, set_default=set_default
)
# Create the project request
project_request = ProjectInfoRequest(
name=project_name, path=project_path, set_default=set_default
)
# Call API to create project
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
status_response = ProjectStatusResponse.model_validate(response.json())
# Call API to create project
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
result = f"{status_response.message}\n\n"
if status_response.new_project:
result += "Project Details:\n"
result += f"• Name: {status_response.new_project.name}\n"
result += f"• Path: {status_response.new_project.path}\n"
if status_response.new_project:
result += "Project Details:\n"
result += f"• Name: {status_response.new_project.name}\n"
result += f"• Path: {status_response.new_project.path}\n"
if set_default:
result += "• Set as default project\n"
if set_default:
result += "• Set as default project\n"
result += "\nProject is now available for use in tool calls.\n"
result += f"Use '{project_name}' as the project parameter in MCP tool calls.\n"
result += "\nProject is now available for use in tool calls.\n"
result += f"Use '{project_name}' as the project parameter in MCP tool calls.\n"
return result
return result
@mcp.tool()
@@ -145,53 +147,54 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
if context: # pragma: no cover
await context.info(f"Deleting project: {project_name}")
if context: # pragma: no cover
await context.info(f"Deleting project: {project_name}")
# Get project info before deletion to validate it exists
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Get project info before deletion to validate it exists
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
project_permalink = generate_permalink(project_name)
target_project = None
for p in project_list.projects:
# Match by permalink (handles case-insensitive input)
if p.permalink == project_permalink:
target_project = p
break
# Also match by name comparison (case-insensitive)
if p.name.lower() == project_name.lower():
target_project = p
break
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
project_permalink = generate_permalink(project_name)
target_project = None
for p in project_list.projects:
# Match by permalink (handles case-insensitive input)
if p.permalink == project_permalink:
target_project = p
break
# Also match by name comparison (case-insensitive)
if p.name.lower() == project_name.lower():
target_project = p
break
if not target_project:
available_projects = [p.name for p in project_list.projects]
raise ValueError(
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
)
if not target_project:
available_projects = [p.name for p in project_list.projects]
raise ValueError(
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
)
# Call API to delete project using URL encoding for special characters
from urllib.parse import quote
# Call API to delete project using URL encoding for special characters
from urllib.parse import quote
encoded_name = quote(target_project.name, safe="")
response = await call_delete(client, f"/projects/{encoded_name}")
status_response = ProjectStatusResponse.model_validate(response.json())
encoded_name = quote(target_project.name, safe="")
response = await call_delete(client, f"/projects/{encoded_name}")
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
result = f"{status_response.message}\n\n"
if status_response.old_project:
result += "Removed project details:\n"
result += f"• Name: {status_response.old_project.name}\n"
if hasattr(status_response.old_project, "path"):
result += f"• Path: {status_response.old_project.path}\n"
if status_response.old_project:
result += "Removed project details:\n"
result += f"• Name: {status_response.old_project.name}\n"
if hasattr(status_response.old_project, "path"):
result += f"• Path: {status_response.old_project.path}\n"
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
result += "Re-add the project to access its content again.\n"
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
result += "Re-add the project to access its content again.\n"
return result
return result
+64 -63
View File
@@ -16,7 +16,7 @@ from fastmcp import Context
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -201,70 +201,71 @@ async def read_content(
"""
logger.info("Reading file", path=path, project=project)
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
url = memory_url_path(path)
url = memory_url_path(path)
# Validate path to prevent path traversal attacks
project_path = active_project.home
if not validate_project_path(url, project_path):
logger.warning(
"Attempted path traversal attack blocked",
path=path,
url=url,
project=active_project.name,
)
return {
"type": "error",
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
}
response = await call_get(client, f"{project_url}/resource/{url}")
content_type = response.headers.get("content-type", "application/octet-stream")
content_length = int(response.headers.get("content-length", 0))
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
# Handle text or json
if content_type.startswith("text/") or content_type == "application/json":
logger.debug("Processing text resource")
return {
"type": "text",
"text": response.text,
"content_type": content_type,
"encoding": "utf-8",
}
# Handle images
elif content_type.startswith("image/"):
logger.debug("Processing image")
img = PILImage.open(io.BytesIO(response.content))
img_bytes = optimize_image(img, content_length)
return {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64.b64encode(img_bytes).decode("utf-8"),
},
}
# Handle other file types
else:
logger.debug(f"Processing binary resource content_type {content_type}")
if content_length > 350000: # pragma: no cover
logger.warning("Document too large for response", size=content_length)
# Validate path to prevent path traversal attacks
project_path = active_project.home
if not validate_project_path(url, project_path):
logger.warning(
"Attempted path traversal attack blocked",
path=path,
url=url,
project=active_project.name,
)
return {
"type": "error",
"error": f"Document size {content_length} bytes exceeds maximum allowed size",
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
}
response = await call_get(client, f"{project_url}/resource/{url}")
content_type = response.headers.get("content-type", "application/octet-stream")
content_length = int(response.headers.get("content-length", 0))
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
# Handle text or json
if content_type.startswith("text/") or content_type == "application/json":
logger.debug("Processing text resource")
return {
"type": "text",
"text": response.text,
"content_type": content_type,
"encoding": "utf-8",
}
# Handle images
elif content_type.startswith("image/"):
logger.debug("Processing image")
img = PILImage.open(io.BytesIO(response.content))
img_bytes = optimize_image(img, content_length)
return {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64.b64encode(img_bytes).decode("utf-8"),
},
}
# Handle other file types
else:
logger.debug(f"Processing binary resource content_type {content_type}")
if content_length > 350000: # pragma: no cover
logger.warning("Document too large for response", size=content_length)
return {
"type": "error",
"error": f"Document size {content_length} bytes exceeds maximum allowed size",
}
return {
"type": "document",
"source": {
"type": "base64",
"media_type": content_type,
"data": base64.b64encode(response.content).decode("utf-8"),
},
}
return {
"type": "document",
"source": {
"type": "base64",
"media_type": content_type,
"data": base64.b64encode(response.content).decode("utf-8"),
},
}
+80 -80
View File
@@ -6,7 +6,7 @@ from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
@@ -77,96 +77,96 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
async with get_client() as client:
# Get and validate the project
active_project = await get_active_project(client, project, context)
# Get and validate the project
active_project = await get_active_project(client, project, context)
# Validate identifier to prevent path traversal attacks
# We need to check both the raw identifier and the processed path
processed_path = memory_url_path(identifier)
project_path = active_project.home
# Validate identifier to prevent path traversal attacks
# We need to check both the raw identifier and the processed path
processed_path = memory_url_path(identifier)
project_path = active_project.home
if not validate_project_path(identifier, project_path) or not validate_project_path(
processed_path, project_path
):
logger.warning(
"Attempted path traversal attack blocked",
identifier=identifier,
processed_path=processed_path,
project=active_project.name,
)
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
if not validate_project_path(identifier, project_path) or not validate_project_path(
processed_path, project_path
):
logger.warning(
"Attempted path traversal attack blocked",
identifier=identifier,
processed_path=processed_path,
project=active_project.name,
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
project_url = active_project.project_url
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier)
path = f"{project_url}/resource/{entity_path}"
logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}")
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
project_url = active_project.project_url
try:
# Try direct lookup first
response = await call_get(client, path, params={"page": page, "page_size": page_size})
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier)
path = f"{project_url}/resource/{entity_path}"
logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}")
# If successful, return the content
if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{path}': {e}")
# Continue to fallback methods
try:
# Try direct lookup first
response = await call_get(client, path, params={"page": page, "page_size": page_size})
# If successful, return the content
if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{path}': {e}")
# Continue to fallback methods
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes.fn(
query=identifier, search_type="title", project=project, context=context
)
# Handle both SearchResponse object and error strings
if title_results and hasattr(title_results, "results") and title_results.results:
result = title_results.results[0] # Get the first/best match
if result.permalink:
try:
# Try to fetch the content using the found permalink
path = f"{project_url}/resource/{result.permalink}"
response = await call_get(
client, path, params={"page": page, "page_size": page_size}
)
if response.status_code == 200:
logger.info(f"Found note by title search: {result.permalink}")
return response.text
except Exception as e: # pragma: no cover
logger.info(
f"Failed to fetch content for found title match {result.permalink}: {e}"
)
else:
logger.info(
f"No results in title search for: {identifier} in project {active_project.name}"
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes.fn(
query=identifier, search_type="title", project=project, context=context
)
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes.fn(
query=identifier, search_type="text", project=project, context=context
)
# Handle both SearchResponse object and error strings
if title_results and hasattr(title_results, "results") and title_results.results:
result = title_results.results[0] # Get the first/best match
if result.permalink:
try:
# Try to fetch the content using the found permalink
path = f"{project_url}/resource/{result.permalink}"
response = await call_get(
client, path, params={"page": page, "page_size": page_size}
)
# We didn't find a direct match, construct a helpful error message
# Handle both SearchResponse object and error strings
if not text_results or not hasattr(text_results, "results") or not text_results.results:
# No results at all
return format_not_found_message(active_project.name, identifier)
else:
# We found some related results
return format_related_results(active_project.name, identifier, text_results.results[:5])
if response.status_code == 200:
logger.info(f"Found note by title search: {result.permalink}")
return response.text
except Exception as e: # pragma: no cover
logger.info(
f"Failed to fetch content for found title match {result.permalink}: {e}"
)
else:
logger.info(
f"No results in title search for: {identifier} in project {active_project.name}"
)
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes.fn(
query=identifier, search_type="text", project=project, context=context
)
# We didn't find a direct match, construct a helpful error message
# Handle both SearchResponse object and error strings
if not text_results or not hasattr(text_results, "results") or not text_results.results:
# No results at all
return format_not_found_message(active_project.name, identifier)
else:
# We found some related results
return format_related_results(active_project.name, identifier, text_results.results[:5])
def format_not_found_message(project: str | None, identifier: str) -> str:
+145 -141
View File
@@ -5,7 +5,7 @@ from typing import List, Union, Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
@@ -98,162 +98,166 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
# Build common parameters for API calls
params = {
"page": 1,
"page_size": 10,
"max_related": 10,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
async with get_client() as client:
# Build common parameters for API calls
params = {
"page": 1,
"page_size": 10,
"max_related": 10,
}
if depth:
params["depth"] = depth
if timeframe:
params["timeframe"] = timeframe # pyright: ignore
# Validate and convert type parameter
if type:
# Convert single string to list
if isinstance(type, str):
type_list = [type]
else:
type_list = type
# Validate each type against SearchItemType enum
validated_types = []
for t in type_list:
try:
# Try to convert string to enum
if isinstance(t, str):
validated_types.append(SearchItemType(t.lower()))
except ValueError:
valid_types = [t.value for t in SearchItemType]
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
# Resolve project parameter using the three-tier hierarchy
resolved_project = await resolve_project_parameter(project)
if resolved_project is None:
# Discovery Mode: Get activity across all projects
logger.info(
f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
)
# Get list of all projects
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
projects_activity = {}
total_items = 0
total_entities = 0
total_relations = 0
total_observations = 0
most_active_project = None
most_active_count = 0
active_projects = 0
# Query each project's activity
for project_info in project_list.projects:
project_activity = await _get_project_activity(client, project_info, params, depth)
projects_activity[project_info.name] = project_activity
# Aggregate stats
item_count = project_activity.item_count
if item_count > 0:
active_projects += 1
total_items += item_count
# Count by type
for result in project_activity.activity.results:
if result.primary_result.type == "entity":
total_entities += 1
elif result.primary_result.type == "relation":
total_relations += 1
elif result.primary_result.type == "observation":
total_observations += 1
# Track most active project
if item_count > most_active_count:
most_active_count = item_count
most_active_project = project_info.name
# Build summary stats
summary = ActivityStats(
total_projects=len(project_list.projects),
active_projects=active_projects,
most_active_project=most_active_project,
total_items=total_items,
total_entities=total_entities,
total_relations=total_relations,
total_observations=total_observations,
)
# Generate guidance for the assistant
guidance_lines = ["\n" + "" * 40]
if most_active_project and most_active_count > 0:
guidance_lines.extend(
[
f"Suggested project: '{most_active_project}' (most active with {most_active_count} items)",
f"Ask user: 'Should I use {most_active_project} for this task, or would you prefer a different project?'",
]
)
elif active_projects > 0:
# Has activity but no clear most active project
active_project_names = [
name for name, activity in projects_activity.items() if activity.item_count > 0
]
if len(active_project_names) == 1:
guidance_lines.extend(
[
f"Suggested project: '{active_project_names[0]}' (only active project)",
f"Ask user: 'Should I use {active_project_names[0]} for this task?'",
]
)
# Validate and convert type parameter
if type:
# Convert single string to list
if isinstance(type, str):
type_list = [type]
else:
type_list = type
# Validate each type against SearchItemType enum
validated_types = []
for t in type_list:
try:
# Try to convert string to enum
if isinstance(t, str):
validated_types.append(SearchItemType(t.lower()))
except ValueError:
valid_types = [t.value for t in SearchItemType]
raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
# Resolve project parameter using the three-tier hierarchy
resolved_project = await resolve_project_parameter(project)
if resolved_project is None:
# Discovery Mode: Get activity across all projects
logger.info(
f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
)
# Get list of all projects
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
projects_activity = {}
total_items = 0
total_entities = 0
total_relations = 0
total_observations = 0
most_active_project = None
most_active_count = 0
active_projects = 0
# Query each project's activity
for project_info in project_list.projects:
project_activity = await _get_project_activity(client, project_info, params, depth)
projects_activity[project_info.name] = project_activity
# Aggregate stats
item_count = project_activity.item_count
if item_count > 0:
active_projects += 1
total_items += item_count
# Count by type
for result in project_activity.activity.results:
if result.primary_result.type == "entity":
total_entities += 1
elif result.primary_result.type == "relation":
total_relations += 1
elif result.primary_result.type == "observation":
total_observations += 1
# Track most active project
if item_count > most_active_count:
most_active_count = item_count
most_active_project = project_info.name
# Build summary stats
summary = ActivityStats(
total_projects=len(project_list.projects),
active_projects=active_projects,
most_active_project=most_active_project,
total_items=total_items,
total_entities=total_entities,
total_relations=total_relations,
total_observations=total_observations,
)
# Generate guidance for the assistant
guidance_lines = ["\n" + "" * 40]
if most_active_project and most_active_count > 0:
guidance_lines.extend(
[
f"Multiple active projects found: {', '.join(active_project_names)}",
"Ask user: 'Which project should I use for this task?'",
f"Suggested project: '{most_active_project}' (most active with {most_active_count} items)",
f"Ask user: 'Should I use {most_active_project} for this task, or would you prefer a different project?'",
]
)
else:
# No recent activity
elif active_projects > 0:
# Has activity but no clear most active project
active_project_names = [
name for name, activity in projects_activity.items() if activity.item_count > 0
]
if len(active_project_names) == 1:
guidance_lines.extend(
[
f"Suggested project: '{active_project_names[0]}' (only active project)",
f"Ask user: 'Should I use {active_project_names[0]} for this task?'",
]
)
else:
guidance_lines.extend(
[
f"Multiple active projects found: {', '.join(active_project_names)}",
"Ask user: 'Which project should I use for this task?'",
]
)
else:
# No recent activity
guidance_lines.extend(
[
"No recent activity found in any project.",
"Consider: Ask which project to use or if they want to create a new one.",
]
)
guidance_lines.extend(
[
"No recent activity found in any project.",
"Consider: Ask which project to use or if they want to create a new one.",
"",
"Session reminder: Remember their project choice throughout this conversation.",
]
)
guidance_lines.extend(
["", "Session reminder: Remember their project choice throughout this conversation."]
)
guidance = "\n".join(guidance_lines)
guidance = "\n".join(guidance_lines)
# Format discovery mode output
return _format_discovery_output(projects_activity, summary, timeframe, guidance)
# Format discovery mode output
return _format_discovery_output(projects_activity, summary, timeframe, guidance)
else:
# Project-Specific Mode: Get activity for specific project
logger.info(
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
)
else:
# Project-Specific Mode: Get activity for specific project
logger.info(
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
)
active_project = await get_active_project(client, resolved_project, context)
project_url = active_project.project_url
active_project = await get_active_project(client, resolved_project, context)
project_url = active_project.project_url
response = await call_get(
client,
f"{project_url}/memory/recent",
params=params,
)
activity_data = GraphContext.model_validate(response.json())
response = await call_get(
client,
f"{project_url}/memory/recent",
params=params,
)
activity_data = GraphContext.model_validate(response.json())
# Format project-specific mode output
return _format_project_output(resolved_project, activity_data, timeframe, type)
# Format project-specific mode output
return _format_project_output(resolved_project, activity_data, timeframe, type)
async def _get_project_activity(
+25 -24
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
@@ -353,31 +353,32 @@ async def search_notes(
if after_date:
search_query.after_date = after_date
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url
logger.info(f"Searching for {search_query} in project {active_project.name}")
logger.info(f"Searching for {search_query} in project {active_project.name}")
try:
response = await call_post(
client,
f"{project_url}/search/",
json=search_query.model_dump(),
params={"page": page, "page_size": page_size},
)
result = SearchResponse.model_validate(response.json())
# Check if we got no results and provide helpful guidance
if not result.results:
logger.info(
f"Search returned no results for query: {query} in project {active_project.name}"
try:
response = await call_post(
client,
f"{project_url}/search/",
json=search_query.model_dump(),
params={"page": page, "page_size": page_size},
)
# Don't treat this as an error, but the user might want guidance
# We return the empty result as normal - the user can decide if they need help
result = SearchResponse.model_validate(response.json())
return result
# Check if we got no results and provide helpful guidance
if not result.results:
logger.info(
f"Search returned no results for query: {query} in project {active_project.name}"
)
# Don't treat this as an error, but the user might want guidance
# We return the empty result as normal - the user can decide if they need help
except Exception as e:
logger.error(f"Search failed for query '{query}': {e}, project: {active_project.name}")
# Return formatted error message as string for better user experience
return _format_search_error_response(active_project.name, str(e), query, search_type)
return result
except Exception as e:
logger.error(f"Search failed for query '{query}': {e}, project: {active_project.name}")
# Return formatted error message as string for better user experience
return _format_search_error_response(active_project.name, str(e), query, search_type)
+128 -123
View File
@@ -6,7 +6,7 @@ from loguru import logger
from fastmcp import Context
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_active_project
from basic_memory.services.sync_status_service import sync_status_tracker
@@ -95,162 +95,167 @@ async def sync_status(project: Optional[str] = None, context: Context | None = N
"""
logger.info("MCP tool call tool=sync_status")
status_lines = []
async with get_client() as client:
status_lines = []
try:
from basic_memory.services.sync_status_service import sync_status_tracker
try:
from basic_memory.services.sync_status_service import sync_status_tracker
# Get overall summary
summary = sync_status_tracker.get_summary()
is_ready = sync_status_tracker.is_ready
# Get overall summary
summary = sync_status_tracker.get_summary()
is_ready = sync_status_tracker.is_ready
# Header
status_lines.extend(
[
"# Basic Memory Sync Status",
"",
f"**Current Status**: {summary}",
f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
"",
]
)
if is_ready:
# Header
status_lines.extend(
[
"✅ **All sync operations completed**",
"# Basic Memory Sync Status",
"",
"- File indexing is complete",
"- Knowledge graphs are up to date",
"- All Basic Memory tools are fully operational",
f"**Current Status**: {summary}",
f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
"",
"Your knowledge base is ready for use!",
]
)
# Show all projects status even when ready
status_lines.extend(_get_all_projects_status())
else:
# System is still processing - show both active and all projects
all_sync_projects = sync_status_tracker.get_all_projects()
active_projects = [
p for p in all_sync_projects.values() if p.status.value in ["scanning", "syncing"]
]
failed_projects = [p for p in all_sync_projects.values() if p.status.value == "failed"]
if active_projects:
if is_ready:
status_lines.extend(
[
"🔄 **File synchronization in progress**",
" **All sync operations completed**",
"",
"Basic Memory is automatically processing all configured projects and building knowledge graphs.",
"This typically takes 1-3 minutes depending on the amount of content.",
"- File indexing is complete",
"- Knowledge graphs are up to date",
"- All Basic Memory tools are fully operational",
"",
"**Currently Processing:**",
"Your knowledge base is ready for use!",
]
)
for project_status in active_projects:
progress = ""
if project_status.files_total > 0:
progress_pct = (
project_status.files_processed / project_status.files_total
) * 100
progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
# Show all projects status even when ready
status_lines.extend(_get_all_projects_status())
else:
# System is still processing - show both active and all projects
all_sync_projects = sync_status_tracker.get_all_projects()
status_lines.append(
f"- **{project_status.project_name}**: {project_status.message}{progress}"
active_projects = [
p
for p in all_sync_projects.values()
if p.status.value in ["scanning", "syncing"]
]
failed_projects = [
p for p in all_sync_projects.values() if p.status.value == "failed"
]
if active_projects:
status_lines.extend(
[
"🔄 **File synchronization in progress**",
"",
"Basic Memory is automatically processing all configured projects and building knowledge graphs.",
"This typically takes 1-3 minutes depending on the amount of content.",
"",
"**Currently Processing:**",
]
)
status_lines.extend(
[
"",
"**What's happening:**",
"- Scanning and indexing markdown files",
"- Building entity and relationship graphs",
"- Setting up full-text search indexes",
"- Processing file changes and updates",
"",
"**What you can do:**",
"- Wait for automatic processing to complete - no action needed",
"- Use this tool again to check progress",
"- Simple operations may work already",
"- All projects will be available once sync finishes",
]
)
for project_status in active_projects:
progress = ""
if project_status.files_total > 0:
progress_pct = (
project_status.files_processed / project_status.files_total
) * 100
progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
# Handle failed projects (independent of active projects)
if failed_projects:
status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
status_lines.append(
f"- **{project_status.project_name}**: {project_status.message}{progress}"
)
for project_status in failed_projects:
status_lines.append(
f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
status_lines.extend(
[
"",
"**What's happening:**",
"- Scanning and indexing markdown files",
"- Building entity and relationship graphs",
"- Settings up full-text search indexes",
"- Processing file changes and updates",
"",
"**What you can do:**",
"- Wait for automatic processing to complete - no action needed",
"- Use this tool again to check progress",
"- Simple operations may work already",
"- All projects will be available once sync finishes",
]
)
status_lines.extend(
[
"",
"**Next steps:**",
"1. Check the logs for detailed error information",
"2. Ensure file permissions allow read/write access",
"3. Try restarting the MCP server",
"4. If issues persist, consider filing a support issue",
]
)
elif not active_projects:
# No active or failed projects - must be pending
status_lines.extend(
[
"⏳ **Sync operations pending**",
"",
"File synchronization has been queued but hasn't started yet.",
"This usually resolves automatically within a few seconds.",
]
)
# Handle failed projects (independent of active projects)
if failed_projects:
status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
# Add comprehensive project status for all configured projects
all_projects_status = _get_all_projects_status()
if all_projects_status:
status_lines.extend(all_projects_status)
for project_status in failed_projects:
status_lines.append(
f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
)
# Add explanation about automatic syncing if there are unsynced projects
unsynced_count = sum(1 for line in all_projects_status if "" in line)
if unsynced_count > 0 and not is_ready:
status_lines.extend(
[
"",
"**Note**: All configured projects will be automatically synced during startup.",
]
)
status_lines.extend(
[
"",
"**Next steps:**",
"1. Check the logs for detailed error information",
"2. Ensure file permissions allow read/write access",
"3. Try restarting the MCP server",
"4. If issues persist, consider filing a support issue",
]
)
elif not active_projects:
# No active or failed projects - must be pending
status_lines.extend(
[
"⏳ **Sync operations pending**",
"",
"File synchronization has been queued but hasn't started yet.",
"This usually resolves automatically within a few seconds.",
]
)
# Add project context if provided
if project:
try:
active_project = await get_active_project(client, project, context)
status_lines.extend(
[
"",
"---",
"",
f"**Active Project**: {active_project.name}",
f"**Project Path**: {active_project.home}",
]
)
except Exception as e:
logger.debug(f"Could not get project info: {e}")
# Add comprehensive project status for all configured projects
all_projects_status = _get_all_projects_status()
if all_projects_status:
status_lines.extend(all_projects_status)
return "\n".join(status_lines)
# Add explanation about automatic syncing if there are unsynced projects
unsynced_count = sum(1 for line in all_projects_status if "" in line)
if unsynced_count > 0 and not is_ready:
status_lines.extend(
[
"",
"**Note**: All configured projects will be automatically synced during startup.",
]
)
except Exception as e:
return f"""# Sync Status - Error
# Add project context if provided
if project:
try:
active_project = await get_active_project(client, project, context)
status_lines.extend(
[
"",
"---",
"",
f"**Active Project**: {active_project.name}",
f"**Project Path**: {active_project.home}",
]
)
except Exception as e:
logger.debug(f"Could not get project info: {e}")
return "\n".join(status_lines)
except Exception as e:
return f"""# Sync Status - Error
❌ **Unable to check sync status**: {str(e)}
**Troubleshooting:**
- The system may still be starting up
- Try waiting a few seconds and checking again
- Try waiting a few seconds and checking again
- Check logs for detailed error information
- Consider restarting if the issue persists
"""
-15
View File
@@ -23,8 +23,6 @@ from httpx._types import (
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools.headers import inject_auth_header
def get_error_message(
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
@@ -110,7 +108,6 @@ async def call_get(
logger.debug(f"Calling GET '{url}' params: '{params}'")
error_message = None
headers = inject_auth_header(headers)
try:
response = await client.get(
url,
@@ -196,9 +193,6 @@ async def call_put(
logger.debug(f"Calling PUT '{url}'")
error_message = None
# Inject JWT from FastMCP context if available
headers = inject_auth_header(headers)
try:
response = await client.put(
url,
@@ -288,9 +282,6 @@ async def call_patch(
"""
logger.debug(f"Calling PATCH '{url}'")
# Inject JWT from FastMCP context if available
headers = inject_auth_header(headers)
try:
response = await client.patch(
url,
@@ -396,9 +387,6 @@ async def call_post(
logger.debug(f"Calling POST '{url}'")
error_message = None
# Inject JWT from FastMCP context if available
headers = inject_auth_header(headers)
try:
response = await client.post(
url=url,
@@ -481,9 +469,6 @@ async def call_delete(
logger.debug(f"Calling DELETE '{url}'")
error_message = None
# Inject JWT from FastMCP context if available
headers = inject_auth_header(headers)
try:
response = await client.delete(
url=url,
+92 -87
View File
@@ -4,7 +4,7 @@ from typing import List, Union, Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
@@ -118,96 +118,101 @@ async def write_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If folder path attempts path traversal
"""
logger.info(
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
)
# Get and validate the project (supports optional project parameter)
active_project = await get_active_project(client, project, context)
# Normalize "/" to empty string for root folder (must happen before validation)
if folder == "/":
folder = ""
# Validate folder path to prevent path traversal attacks
project_path = active_project.home
if folder and not validate_project_path(folder, project_path):
logger.warning(
"Attempted path traversal attack blocked", folder=folder, project=active_project.name
async with get_client() as client:
logger.info(
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
)
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
# Get and validate the project (supports optional project parameter)
active_project = await get_active_project(client, project, context)
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
# Normalize "/" to empty string for root folder (must happen before validation)
if folder == "/":
folder = ""
# Process tags using the helper function
tag_list = parse_tags(tags)
# Create the entity request
metadata = {"tags": tag_list} if tag_list else None
entity = Entity(
title=title,
folder=folder,
entity_type=entity_type,
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
project_url = active_project.permalink
# Create or update via knowledge API
logger.debug(f"Creating entity via API permalink={entity.permalink}")
url = f"{project_url}/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
summary = [
f"# {action} note",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
# Count observations by category
categories = {}
if result.observations:
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
summary.append(
"They will be automatically resolved when target entities are created or during sync operations."
# Validate folder path to prevent path traversal attacks
project_path = active_project.home
if folder and not validate_project_path(folder, project_path):
logger.warning(
"Attempted path traversal attack blocked",
folder=folder,
project=active_project.name,
)
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
if tag_list:
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
# Log the response with structured data
logger.info(
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
)
result = "\n".join(summary)
return add_project_metadata(result, active_project.name)
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
# Process tags using the helper function
tag_list = parse_tags(tags)
# Create the entity request
metadata = {"tags": tag_list} if tag_list else None
entity = Entity(
title=title,
folder=folder,
entity_type=entity_type,
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
project_url = active_project.permalink
# Create or update via knowledge API
logger.debug(f"Creating entity via API permalink={entity.permalink}")
url = f"{project_url}/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
summary = [
f"# {action} note",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
# Count observations by category
categories = {}
if result.observations:
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
summary.append(
"\nNote: Unresolved relations point to entities that don't exist yet."
)
summary.append(
"They will be automatically resolved when target entities are created or during sync operations."
)
if tag_list:
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
# Log the response with structured data
logger.info(
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
)
result = "\n".join(summary)
return add_project_metadata(result, active_project.name)