Skip to content

Commit 0bcda4a

Browse files
phernandezclaude
andcommitted
fix: allow recent_activity discovery mode in cloud mode
Add `allow_discovery` parameter to `resolve_project_parameter()` that enables tools like `recent_activity` to work across all projects in cloud mode without requiring a project parameter. - Add `allow_discovery: bool = False` param to resolve_project_parameter - In cloud mode with allow_discovery=True, return None instead of error - Update recent_activity to use allow_discovery=True - Fix circular import by deferring call_get import inside functions - Add comprehensive tests for resolve_project_parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> Signed-off-by: phernandez <[email protected]>
1 parent 7a49f57 commit 0bcda4a

File tree

3 files changed

+162
-5
lines changed

3 files changed

+162
-5
lines changed

src/basic_memory/mcp/project_context.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,17 @@
1414
from fastmcp import Context
1515

1616
from basic_memory.config import ConfigManager
17-
from basic_memory.mcp.tools.utils import call_get
1817
from basic_memory.schemas.project_info import ProjectItem, ProjectList
1918
from basic_memory.utils import generate_permalink
2019

2120

22-
async def resolve_project_parameter(project: Optional[str] = None) -> Optional[str]:
21+
async def resolve_project_parameter(
22+
project: Optional[str] = None, allow_discovery: bool = False
23+
) -> Optional[str]:
2324
"""Resolve project parameter using three-tier hierarchy.
2425
2526
if config.cloud_mode:
26-
project is required
27+
project is required (unless allow_discovery=True for tools that support discovery mode)
2728
else:
2829
Resolution order:
2930
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
@@ -32,17 +33,22 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
3233
3334
Args:
3435
project: Optional explicit project parameter
36+
allow_discovery: If True, allows returning None in cloud mode for discovery mode
37+
(used by tools like recent_activity that can operate across all projects)
3538
3639
Returns:
3740
Resolved project name or None if no resolution possible
3841
"""
3942

4043
config = ConfigManager().config
41-
# if cloud_mode, project is required
44+
# if cloud_mode, project is required (unless discovery mode is allowed)
4245
if config.cloud_mode:
4346
if project:
4447
logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
4548
return project
49+
elif allow_discovery:
50+
logger.debug("cloud_mode: discovery mode allowed, returning None")
51+
return None
4652
else:
4753
raise ValueError("No project specified. Project is required for cloud mode.")
4854

@@ -67,6 +73,9 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
6773

6874

6975
async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
76+
# Deferred import to avoid circular dependency with tools
77+
from basic_memory.mcp.tools.utils import call_get
78+
7079
response = await call_get(client, "/projects/projects", headers=headers)
7180
project_list = ProjectList.model_validate(response.json())
7281
return [project.name for project in project_list.projects]
@@ -92,6 +101,9 @@ async def get_active_project(
92101
ValueError: If no project can be resolved
93102
HTTPError: If project doesn't exist or is inaccessible
94103
"""
104+
# Deferred import to avoid circular dependency with tools
105+
from basic_memory.mcp.tools.utils import call_get
106+
95107
resolved_project = await resolve_project_parameter(project)
96108
if not resolved_project:
97109
project_names = await get_project_names(client, headers)

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ async def recent_activity(
135135
params["type"] = [t.value for t in validated_types] # pyright: ignore
136136

137137
# Resolve project parameter using the three-tier hierarchy
138-
resolved_project = await resolve_project_parameter(project)
138+
# allow_discovery=True enables Discovery Mode, so a project is not required
139+
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
139140

140141
if resolved_project is None:
141142
# Discovery Mode: Get activity across all projects

tests/mcp/test_project_context.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for project context utilities."""
2+
3+
import os
4+
from unittest.mock import patch, MagicMock
5+
6+
import pytest
7+
8+
9+
class TestResolveProjectParameter:
10+
"""Tests for resolve_project_parameter function."""
11+
12+
@pytest.mark.asyncio
13+
async def test_cloud_mode_requires_project_by_default(self):
14+
"""In cloud mode, project is required when allow_discovery=False."""
15+
from basic_memory.mcp.project_context import resolve_project_parameter
16+
17+
mock_config = MagicMock()
18+
mock_config.cloud_mode = True
19+
20+
with patch(
21+
"basic_memory.mcp.project_context.ConfigManager"
22+
) as mock_config_manager:
23+
mock_config_manager.return_value.config = mock_config
24+
25+
with pytest.raises(ValueError) as exc_info:
26+
await resolve_project_parameter(project=None, allow_discovery=False)
27+
28+
assert "No project specified" in str(exc_info.value)
29+
assert "Project is required for cloud mode" in str(exc_info.value)
30+
31+
@pytest.mark.asyncio
32+
async def test_cloud_mode_allows_discovery_when_enabled(self):
33+
"""In cloud mode with allow_discovery=True, returns None instead of error."""
34+
from basic_memory.mcp.project_context import resolve_project_parameter
35+
36+
mock_config = MagicMock()
37+
mock_config.cloud_mode = True
38+
39+
with patch(
40+
"basic_memory.mcp.project_context.ConfigManager"
41+
) as mock_config_manager:
42+
mock_config_manager.return_value.config = mock_config
43+
44+
result = await resolve_project_parameter(project=None, allow_discovery=True)
45+
46+
assert result is None
47+
48+
@pytest.mark.asyncio
49+
async def test_cloud_mode_returns_project_when_specified(self):
50+
"""In cloud mode, returns the specified project."""
51+
from basic_memory.mcp.project_context import resolve_project_parameter
52+
53+
mock_config = MagicMock()
54+
mock_config.cloud_mode = True
55+
56+
with patch(
57+
"basic_memory.mcp.project_context.ConfigManager"
58+
) as mock_config_manager:
59+
mock_config_manager.return_value.config = mock_config
60+
61+
result = await resolve_project_parameter(project="my-project")
62+
63+
assert result == "my-project"
64+
65+
@pytest.mark.asyncio
66+
async def test_local_mode_uses_env_var_priority(self):
67+
"""In local mode, BASIC_MEMORY_MCP_PROJECT env var takes priority."""
68+
from basic_memory.mcp.project_context import resolve_project_parameter
69+
70+
mock_config = MagicMock()
71+
mock_config.cloud_mode = False
72+
73+
with patch(
74+
"basic_memory.mcp.project_context.ConfigManager"
75+
) as mock_config_manager:
76+
mock_config_manager.return_value.config = mock_config
77+
78+
with patch.dict(os.environ, {"BASIC_MEMORY_MCP_PROJECT": "env-project"}):
79+
result = await resolve_project_parameter(project="explicit-project")
80+
81+
# Env var should take priority over explicit project
82+
assert result == "env-project"
83+
84+
@pytest.mark.asyncio
85+
async def test_local_mode_uses_explicit_project(self):
86+
"""In local mode without env var, uses explicit project parameter."""
87+
from basic_memory.mcp.project_context import resolve_project_parameter
88+
89+
mock_config = MagicMock()
90+
mock_config.cloud_mode = False
91+
mock_config.default_project_mode = False
92+
93+
with patch(
94+
"basic_memory.mcp.project_context.ConfigManager"
95+
) as mock_config_manager:
96+
mock_config_manager.return_value.config = mock_config
97+
98+
with patch.dict(os.environ, {}, clear=True):
99+
# Remove the env var if it exists
100+
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
101+
result = await resolve_project_parameter(project="explicit-project")
102+
103+
assert result == "explicit-project"
104+
105+
@pytest.mark.asyncio
106+
async def test_local_mode_uses_default_project(self):
107+
"""In local mode with default_project_mode, uses default project."""
108+
from basic_memory.mcp.project_context import resolve_project_parameter
109+
110+
mock_config = MagicMock()
111+
mock_config.cloud_mode = False
112+
mock_config.default_project_mode = True
113+
mock_config.default_project = "default-project"
114+
115+
with patch(
116+
"basic_memory.mcp.project_context.ConfigManager"
117+
) as mock_config_manager:
118+
mock_config_manager.return_value.config = mock_config
119+
120+
with patch.dict(os.environ, {}, clear=True):
121+
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
122+
result = await resolve_project_parameter(project=None)
123+
124+
assert result == "default-project"
125+
126+
@pytest.mark.asyncio
127+
async def test_local_mode_returns_none_when_no_resolution(self):
128+
"""In local mode without any project source, returns None."""
129+
from basic_memory.mcp.project_context import resolve_project_parameter
130+
131+
mock_config = MagicMock()
132+
mock_config.cloud_mode = False
133+
mock_config.default_project_mode = False
134+
135+
with patch(
136+
"basic_memory.mcp.project_context.ConfigManager"
137+
) as mock_config_manager:
138+
mock_config_manager.return_value.config = mock_config
139+
140+
with patch.dict(os.environ, {}, clear=True):
141+
os.environ.pop("BASIC_MEMORY_MCP_PROJECT", None)
142+
result = await resolve_project_parameter(project=None)
143+
144+
assert result is None

0 commit comments

Comments
 (0)