mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
117fa44ecf
Signed-off-by: phernandez <paul@basicmachines.co>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Project management tools for Basic Memory MCP server.
|
|
|
|
These tools allow users to switch between projects, list available projects,
|
|
and manage project context during conversations.
|
|
"""
|
|
|
|
from textwrap import dedent
|
|
|
|
from fastmcp import Context
|
|
from loguru import logger
|
|
|
|
from basic_memory.config import get_project_config
|
|
from basic_memory.mcp.async_client import client
|
|
from basic_memory.mcp.project_session import session, add_project_metadata
|
|
from basic_memory.mcp.server import mcp
|
|
from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete
|
|
from basic_memory.schemas import ProjectInfoResponse
|
|
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest
|
|
|
|
|
|
@mcp.tool()
|
|
async def list_projects(ctx: Context | None = None) -> str:
|
|
"""List all available projects with their status.
|
|
|
|
Shows all Basic Memory projects that are available, indicating which one
|
|
is currently active and which is the default.
|
|
|
|
Returns:
|
|
Formatted list of projects with status indicators
|
|
|
|
Example:
|
|
list_projects()
|
|
"""
|
|
if ctx: # pragma: no cover
|
|
await ctx.info("Listing all available projects")
|
|
|
|
# Get projects from API
|
|
response = await call_get(client, "/projects/projects")
|
|
project_list = ProjectList.model_validate(response.json())
|
|
|
|
current = session.get_current_project()
|
|
|
|
result = "Available projects:\n"
|
|
|
|
for project in project_list.projects:
|
|
indicators = []
|
|
if project.name == current:
|
|
indicators.append("current")
|
|
if project.is_default:
|
|
indicators.append("default")
|
|
|
|
if indicators:
|
|
result += f"• {project.name} ({', '.join(indicators)})\n"
|
|
else:
|
|
result += f"• {project.name}\n"
|
|
|
|
return add_project_metadata(result, current)
|
|
|
|
|
|
@mcp.tool()
|
|
async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
|
"""Switch to a different project context.
|
|
|
|
Changes the active project context for all subsequent tool calls.
|
|
Shows a project summary after switching successfully.
|
|
|
|
Args:
|
|
project_name: Name of the project to switch to
|
|
|
|
Returns:
|
|
Confirmation message with project summary
|
|
|
|
Example:
|
|
switch_project("work-notes")
|
|
switch_project("personal-journal")
|
|
"""
|
|
if ctx: # pragma: no cover
|
|
await ctx.info(f"Switching to project: {project_name}")
|
|
|
|
current_project = session.get_current_project()
|
|
try:
|
|
# Validate project exists by getting project list
|
|
response = await call_get(client, "/projects/projects")
|
|
project_list = ProjectList.model_validate(response.json())
|
|
|
|
# Check if project exists
|
|
project_exists = any(p.name == project_name for p in project_list.projects)
|
|
if not project_exists:
|
|
available_projects = [p.name for p in project_list.projects]
|
|
return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
|
|
|
# Switch to the project
|
|
session.set_current_project(project_name)
|
|
current_project = session.get_current_project()
|
|
project_config = get_project_config(current_project)
|
|
|
|
# Get project info to show summary
|
|
try:
|
|
response = await call_get(
|
|
client,
|
|
f"{project_config.project_url}/project/info",
|
|
params={"project_name": project_name},
|
|
)
|
|
project_info = ProjectInfoResponse.model_validate(response.json())
|
|
|
|
result = f"✓ Switched to {project_name} project\n\n"
|
|
result += "Project Summary:\n"
|
|
result += f"• {project_info.statistics.total_entities} entities\n"
|
|
result += f"• {project_info.statistics.total_observations} observations\n"
|
|
result += f"• {project_info.statistics.total_relations} relations\n"
|
|
|
|
except Exception as e:
|
|
# If we can't get project info, still confirm the switch
|
|
logger.warning(f"Could not get project info for {project_name}: {e}")
|
|
result = f"✓ Switched to {project_name} project\n\n"
|
|
result += "Project summary unavailable.\n"
|
|
|
|
return add_project_metadata(result, project_name)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error switching to project {project_name}: {e}")
|
|
# Revert to previous project on error
|
|
session.set_current_project(current_project)
|
|
|
|
# Return user-friendly error message instead of raising exception
|
|
return dedent(f"""
|
|
# Project Switch Failed
|
|
|
|
Could not switch to project '{project_name}': {str(e)}
|
|
|
|
## Current project: {current_project}
|
|
Your session remains on the previous project.
|
|
|
|
## Troubleshooting:
|
|
1. **Check available projects**: Use `list_projects()` to see valid project names
|
|
2. **Verify spelling**: Ensure the project name is spelled correctly
|
|
3. **Check permissions**: Verify you have access to the requested project
|
|
4. **Try again**: The error might be temporary
|
|
|
|
## Available options:
|
|
- See all projects: `list_projects()`
|
|
- Stay on current project: `get_current_project()`
|
|
- Try different project: `switch_project("correct-project-name")`
|
|
|
|
If the project should exist but isn't listed, send a message to support@basicmachines.co.
|
|
""").strip()
|
|
|
|
|
|
@mcp.tool()
|
|
async def get_current_project(ctx: Context | None = None) -> str:
|
|
"""Show the currently active project and basic stats.
|
|
|
|
Displays which project is currently active and provides basic information
|
|
about it.
|
|
|
|
Returns:
|
|
Current project name and basic statistics
|
|
|
|
Example:
|
|
get_current_project()
|
|
"""
|
|
if ctx: # pragma: no cover
|
|
await ctx.info("Getting current project information")
|
|
|
|
current_project = session.get_current_project()
|
|
project_config = get_project_config(current_project)
|
|
result = f"Current project: {current_project}\n\n"
|
|
|
|
# get project stats
|
|
response = await call_get(
|
|
client,
|
|
f"{project_config.project_url}/project/info",
|
|
params={"project_name": current_project},
|
|
)
|
|
project_info = ProjectInfoResponse.model_validate(response.json())
|
|
|
|
result += f"• {project_info.statistics.total_entities} entities\n"
|
|
result += f"• {project_info.statistics.total_observations} observations\n"
|
|
result += f"• {project_info.statistics.total_relations} relations\n"
|
|
|
|
default_project = session.get_default_project()
|
|
if current_project != default_project:
|
|
result += f"• Default project: {default_project}\n"
|
|
|
|
return add_project_metadata(result, current_project)
|
|
|
|
|
|
@mcp.tool()
|
|
async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
|
|
"""Set default project in config. Requires restart to take effect.
|
|
|
|
Updates the configuration to use a different default project. This change
|
|
only takes effect after restarting the Basic Memory server.
|
|
|
|
Args:
|
|
project_name: Name of the project to set as default
|
|
|
|
Returns:
|
|
Confirmation message about config update
|
|
|
|
Example:
|
|
set_default_project("work-notes")
|
|
"""
|
|
if ctx: # pragma: no cover
|
|
await ctx.info(f"Setting default project to: {project_name}")
|
|
|
|
# Call API to set default project
|
|
response = await call_put(client, f"/projects/{project_name}/default")
|
|
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
|
|
result = f"✓ {status_response.message}\n\n"
|
|
result += "Restart Basic Memory for this change to take effect:\n"
|
|
result += "basic-memory mcp\n"
|
|
|
|
if status_response.old_project:
|
|
result += f"\nPrevious default: {status_response.old_project.name}\n"
|
|
|
|
return add_project_metadata(result, session.get_current_project())
|
|
|
|
|
|
@mcp.tool()
|
|
async def create_project(
|
|
project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
|
|
) -> str:
|
|
"""Create a new Basic Memory project.
|
|
|
|
Creates a new project with the specified name and path. The project directory
|
|
will be created if it doesn't exist. Optionally sets the new project as default.
|
|
|
|
Args:
|
|
project_name: Name for the new project (must be unique)
|
|
project_path: File system path where the project will be stored
|
|
set_default: Whether to set this project as the default (optional, defaults to False)
|
|
|
|
Returns:
|
|
Confirmation message with project details
|
|
|
|
Example:
|
|
create_project("my-research", "~/Documents/research")
|
|
create_project("work-notes", "/home/user/work", set_default=True)
|
|
"""
|
|
if ctx: # pragma: no cover
|
|
await ctx.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
|
|
)
|
|
|
|
# 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"
|
|
|
|
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"
|
|
|
|
result += "\nProject is now available for use.\n"
|
|
|
|
# If project was set as default, update session
|
|
if set_default:
|
|
session.set_current_project(project_name)
|
|
|
|
return add_project_metadata(result, session.get_current_project())
|
|
|
|
|
|
@mcp.tool()
|
|
async def delete_project(project_name: str, ctx: Context | None = None) -> str:
|
|
"""Delete a Basic Memory project.
|
|
|
|
Removes a project from the configuration and database. This does NOT delete
|
|
the actual files on disk - only removes the project from Basic Memory's
|
|
configuration and database records.
|
|
|
|
Args:
|
|
project_name: Name of the project to delete
|
|
|
|
Returns:
|
|
Confirmation message about project deletion
|
|
|
|
Example:
|
|
delete_project("old-project")
|
|
|
|
Warning:
|
|
This action cannot be undone. The project will need to be re-added
|
|
to access its content through Basic Memory again.
|
|
"""
|
|
if ctx: # pragma: no cover
|
|
await ctx.info(f"Deleting project: {project_name}")
|
|
|
|
current_project = session.get_current_project()
|
|
|
|
# Check if trying to delete current project
|
|
if project_name == current_project:
|
|
raise ValueError(
|
|
f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
|
|
)
|
|
|
|
# Get project info before deletion to validate it exists
|
|
response = await call_get(client, "/projects/projects")
|
|
project_list = ProjectList.model_validate(response.json())
|
|
|
|
# Check if project exists
|
|
project_exists = any(p.name == project_name for p in project_list.projects)
|
|
if not project_exists:
|
|
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
|
|
response = await call_delete(client, f"/projects/{project_name}")
|
|
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
|
|
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"
|
|
|
|
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 add_project_metadata(result, session.get_current_project())
|