Skip to content

Commit 3a6baf8

Browse files
jope-bmphernandezclaude
authored
feat: Merge Cloud auth (#291)
Signed-off-by: phernandez <paul@basicmachines.co> Signed-off-by: Joe P <joe@basicmemory.com> Co-authored-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec2fa07 commit 3a6baf8

42 files changed

Lines changed: 1237 additions & 953 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ dependencies = [
3030
"alembic>=1.14.1",
3131
"pillow>=11.1.0",
3232
"pybars3>=0.9.7",
33-
"fastmcp==2.10.2",
33+
"fastmcp>=2.10.2",
3434
"pyjwt>=2.10.1",
3535
"python-dotenv>=1.1.0",
3636
"pytest-aio>=1.9.0",

src/basic_memory/api/routers/project_router.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515

1616
# Router for resources in a specific project
17+
# The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
1718
project_router = APIRouter(prefix="/project", tags=["project"])
1819

1920
# Router for managing project resources
@@ -29,6 +30,25 @@ async def get_project_info(
2930
return await project_service.get_project_info(project)
3031

3132

33+
@project_router.get("/item", response_model=ProjectItem)
34+
async def get_project(
35+
project_service: ProjectServiceDep,
36+
project: ProjectPathDep,
37+
) -> ProjectItem:
38+
"""Get bassic info about the specified Basic Memory project."""
39+
found_project = await project_service.get_project(project)
40+
if not found_project:
41+
raise HTTPException(
42+
status_code=404, detail=f"Project: '{project}' does not exist"
43+
) # pragma: no cover
44+
45+
return ProjectItem(
46+
name=found_project.name,
47+
path=found_project.path,
48+
is_default=found_project.is_default or False,
49+
)
50+
51+
3252
# Update a project
3353
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
3454
async def update_project(
@@ -217,6 +237,27 @@ async def set_default_project(
217237
raise HTTPException(status_code=400, detail=str(e))
218238

219239

240+
# Get the default project
241+
@project_resource_router.get("/default", response_model=ProjectItem)
242+
async def get_default_project(
243+
project_service: ProjectServiceDep,
244+
) -> ProjectItem:
245+
"""Get the default project.
246+
247+
Returns:
248+
Response with project default information
249+
"""
250+
# Get the old default project
251+
default_name = project_service.default_project
252+
default_project = await project_service.get_project(default_name)
253+
if not default_project: # pragma: no cover
254+
raise HTTPException( # pragma: no cover
255+
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
256+
)
257+
258+
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
259+
260+
220261
# Synchronize projects between config and database
221262
@project_resource_router.post("/sync", response_model=ProjectStatusResponse)
222263
async def synchronize_projects(

src/basic_memory/cli/app.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import typer
44

55
from basic_memory.config import get_project_config, ConfigManager
6-
from basic_memory.mcp.project_session import session
76

87

98
def version_callback(value: bool) -> None:
@@ -28,7 +27,7 @@ def app_callback(
2827
None,
2928
"--project",
3029
"-p",
31-
help="Specify which project to use 1",
30+
help="Specify which project to use",
3231
envvar="BASIC_MEMORY_PROJECT",
3332
),
3433
version: Optional[bool] = typer.Option(
@@ -51,18 +50,11 @@ def app_callback(
5150

5251
# Initialize MCP session with the specified project or default
5352
if project: # pragma: no cover
54-
# Use the project specified via --project flag
55-
current_project_config = get_project_config(project)
56-
session.set_current_project(current_project_config.name)
57-
5853
# Update the global config to use this project
5954
from basic_memory.config import update_current_project
6055

56+
# TODO set active project via cli
6157
update_current_project(project)
62-
else:
63-
# Use the default project
64-
current_project = app_config.default_project
65-
session.set_current_project(current_project)
6658

6759

6860
# Register sub-command groups

src/basic_memory/cli/commands/mcp.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from basic_memory.cli.app import app
77
from basic_memory.config import ConfigManager
8+
from basic_memory.mcp.middleware import ProjectContextMiddleware
89

910
# Import mcp instance
1011
from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
@@ -63,6 +64,9 @@ def run_file_sync():
6364
# Now run the MCP server (blocks)
6465
logger.info(f"Starting MCP server with {transport.upper()} transport")
6566

67+
# middleware to store the active project
68+
mcp_server.add_middleware(ProjectContextMiddleware())
69+
6670
if transport == "stdio":
6771
mcp_server.run(
6872
transport=transport,

src/basic_memory/cli/commands/project.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from rich.table import Table
1010

1111
from basic_memory.cli.app import app
12-
from basic_memory.mcp.project_session import session
1312
from basic_memory.mcp.resources.project_info import project_info
1413
import json
1514
from datetime import datetime
@@ -52,13 +51,11 @@ def list_projects() -> None:
5251
table = Table(title="Basic Memory Projects")
5352
table.add_column("Name", style="cyan")
5453
table.add_column("Path", style="green")
55-
table.add_column("Default", style="yellow")
56-
table.add_column("Active", style="magenta")
54+
table.add_column("Default", style="magenta")
5755

5856
for project in result.projects:
5957
is_default = "✓" if project.is_default else ""
60-
is_active = "✓" if session.get_current_project() == project.name else ""
61-
table.add_row(project.name, format_path(project.path), is_default, is_active)
58+
table.add_row(project.name, format_path(project.path), is_default)
6259

6360
console.print(table)
6461
except Exception as e:
@@ -161,9 +158,10 @@ def move_project(
161158
data = {"path": resolved_path}
162159

163160
project_permalink = generate_permalink(name)
164-
current_project = session.get_current_project()
161+
162+
# TODO fix route to use ProjectPathDep
165163
response = asyncio.run(
166-
call_patch(client, f"/{current_project}/project/{project_permalink}", json=data)
164+
call_patch(client, f"/{name}/project/{project_permalink}", json=data)
167165
)
168166
result = ProjectStatusResponse.model_validate(response.json())
169167

src/basic_memory/mcp/async_client.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1+
import os
12
from httpx import ASGITransport, AsyncClient
23
from loguru import logger
34

45
from basic_memory.api.app import app as fastapi_app
5-
from basic_memory.config import ConfigManager
66

77

88
def create_client() -> AsyncClient:
99
"""Create an HTTP client based on configuration.
1010
1111
Returns:
12-
AsyncClient configured for either local ASGI or remote HTTP transport
12+
AsyncClient configured for either local ASGI or remote proxy
1313
"""
14-
config_manager = ConfigManager()
15-
config = config_manager.load_config()
14+
proxy_base_url = os.getenv("BASIC_MEMORY_PROXY_URL", None)
15+
logger.info(f"BASIC_MEMORY_PROXY_URL: {proxy_base_url}")
1616

17-
if config.api_url:
18-
# Use HTTP transport for remote API
19-
logger.info(f"Creating HTTP client for remote Basic Memory API: {config.api_url}")
20-
return AsyncClient(base_url=config.api_url)
17+
if proxy_base_url:
18+
# Use HTTP transport to proxy endpoint
19+
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
20+
return AsyncClient(base_url=proxy_base_url)
2121
else:
22-
# Use ASGI transport for local API
22+
# Default: use ASGI transport for local API (development mode)
2323
logger.debug("Creating ASGI client for local Basic Memory API")
2424
return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
2525

src/basic_memory/mcp/middleware.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import Any
2+
from loguru import logger
3+
from fastmcp.server.middleware import Middleware, MiddlewareContext
4+
5+
from basic_memory.config import ConfigManager
6+
from basic_memory.schemas.project_info import ProjectItem
7+
8+
9+
class ProjectContextMiddleware(Middleware):
10+
def __init__(self):
11+
config = ConfigManager().config
12+
default_project_name = config.default_project
13+
default_project_path = config.projects[default_project_name]
14+
15+
self.default_project = ProjectItem(
16+
name=default_project_name, path=default_project_path, is_default=True
17+
)
18+
self.sessions: dict[str, ProjectItem] = {}
19+
20+
async def on_call_tool(self, context: MiddlewareContext, call_next) -> Any:
21+
# Initialize context with default project if not set
22+
if context.fastmcp_context:
23+
session_id = context.fastmcp_context.session_id
24+
project = self.sessions.get(session_id)
25+
26+
active_project = project or self.default_project
27+
28+
# store in sessions
29+
if not project:
30+
self.sessions[session_id] = active_project
31+
32+
# set active project on context
33+
context.fastmcp_context.set_state("active_project", active_project)
34+
logger.debug(
35+
f"project context set: session_id={session_id}, active_project={active_project}"
36+
)
37+
38+
# call tool
39+
result = await call_next(context)
40+
41+
# set global state for if the project changed during tool call
42+
if context.fastmcp_context:
43+
session_id = context.fastmcp_context.session_id
44+
active_project = context.fastmcp_context.get_state("active_project")
45+
project = self.sessions.get(session_id)
46+
if project != active_project:
47+
logger.debug(
48+
f"project session saved: session_id={session_id}, active_project={active_project}"
49+
)
50+
self.sessions[session_id] = active_project
51+
52+
return result
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Project session management for Basic Memory MCP server.
2+
3+
Provides simple in-memory project context for MCP tools, allowing users to switch
4+
between projects during a conversation without restarting the server.
5+
6+
Session Persistence Flow:
7+
1. Pre-tool: Middleware loads self.sessions[session_id] → sets context state
8+
2. Tool execution: Uses context.get_state("active_project")
9+
3. Project switching: Tool calls set_active_project() → updates context state
10+
4. Post-tool: Middleware detects context change → saves to self.sessions[session_id]
11+
"""
12+
13+
from typing import Optional
14+
from httpx import AsyncClient
15+
from loguru import logger
16+
17+
from fastmcp import Context
18+
19+
from basic_memory.mcp.tools.utils import call_get
20+
from basic_memory.schemas.project_info import ProjectItem
21+
from basic_memory.utils import generate_permalink
22+
23+
24+
async def set_active_project(
25+
client: AsyncClient, *, context: Context | None, project: ProjectItem
26+
) -> None:
27+
"""Set the active project context.
28+
29+
Args:
30+
client (AsyncClient): The client to use for making requests to fastapi.
31+
project_name: The project to switch to
32+
context: Optional FastMCP context containing project session state
33+
"""
34+
active_project = await get_active_project(client, context=context)
35+
if active_project.name != project.name:
36+
previous_project = active_project
37+
# set project in the context
38+
if context:
39+
context.set_state("active_project", project)
40+
await context.info(
41+
f"Context {context.session_id} Switched active project: {previous_project} -> {project.name}"
42+
)
43+
logger.info(f"Switched active project: {previous_project} -> {project.name}")
44+
else:
45+
logger.debug(f"No change for active project: {active_project}")
46+
47+
48+
async def get_active_project(
49+
client: AsyncClient, *, context: Context | None, project_override: Optional[str] = None
50+
) -> ProjectItem:
51+
"""
52+
Get the active project for a tool call.
53+
If no context is provided, the active project is returned from the default project
54+
55+
Args:
56+
client (AsyncClient): The client to use for making requests to fastapi.
57+
project_override: Optional explicit project name from tool parameter
58+
context: Optional FastMCP context containing project session state
59+
60+
Returns:
61+
The project to use for the tool call
62+
"""
63+
64+
# Try to get active project from context first
65+
if context:
66+
active_project = context.get_state("active_project")
67+
logger.debug(f"Context {context.session_id} found active project: {active_project}")
68+
else:
69+
response = await call_get(
70+
client,
71+
"/projects/default",
72+
)
73+
active_project = ProjectItem.model_validate(response.json())
74+
logger.debug(f"No context provided. Using default project: {active_project}")
75+
76+
# Handle project override
77+
if project_override:
78+
logger.debug(f"overriding active project: {project_override}")
79+
permalink = generate_permalink(project_override)
80+
response = await call_get(
81+
client,
82+
f"/{permalink}/project/item",
83+
)
84+
active_project = ProjectItem.model_validate(response.json())
85+
86+
return active_project
87+
88+
89+
def add_project_metadata(result: str, project_name: str) -> str:
90+
"""Add project context as metadata footer for LLM awareness.
91+
92+
Args:
93+
result: The tool result string
94+
project_name: The project name that was used
95+
96+
Returns:
97+
Result with project metadata footer
98+
"""
99+
return f"{result}\n\n<!-- Project: {project_name} -->" # pragma: no cover

0 commit comments

Comments
 (0)