basic-memory 0.14.2__py3-none-any.whl → 0.14.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +3 -1
- basic_memory/api/app.py +4 -1
- basic_memory/api/routers/management_router.py +3 -1
- basic_memory/api/routers/project_router.py +21 -13
- basic_memory/cli/app.py +3 -3
- basic_memory/cli/commands/__init__.py +1 -2
- basic_memory/cli/commands/db.py +5 -5
- basic_memory/cli/commands/import_chatgpt.py +3 -2
- basic_memory/cli/commands/import_claude_conversations.py +3 -1
- basic_memory/cli/commands/import_claude_projects.py +3 -1
- basic_memory/cli/commands/import_memory_json.py +5 -2
- basic_memory/cli/commands/mcp.py +3 -15
- basic_memory/cli/commands/project.py +41 -0
- basic_memory/cli/commands/status.py +4 -1
- basic_memory/cli/commands/sync.py +10 -2
- basic_memory/cli/main.py +0 -1
- basic_memory/config.py +46 -31
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/importers/chatgpt_importer.py +19 -9
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/mcp/async_client.py +22 -2
- basic_memory/mcp/project_session.py +6 -4
- basic_memory/mcp/prompts/__init__.py +0 -2
- basic_memory/mcp/server.py +8 -71
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +12 -0
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/project.py +3 -3
- basic_memory/repository/project_repository.py +18 -0
- basic_memory/schemas/importer.py +1 -0
- basic_memory/services/entity_service.py +49 -3
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +85 -28
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +50 -1
- basic_memory/utils.py +105 -4
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/METADATA +2 -2
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/RECORD +45 -51
- basic_memory/cli/commands/auth.py +0 -136
- basic_memory/mcp/auth_provider.py +0 -270
- basic_memory/mcp/external_auth_provider.py +0 -321
- basic_memory/mcp/prompts/sync_status.py +0 -112
- basic_memory/mcp/supabase_auth_provider.py +0 -463
- basic_memory/services/migration_service.py +0 -168
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/licenses/LICENSE +0 -0
basic_memory/config.py
CHANGED
|
@@ -74,6 +74,12 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
74
74
|
description="Whether to sync changes in real time. default (True)",
|
|
75
75
|
)
|
|
76
76
|
|
|
77
|
+
# API connection configuration
|
|
78
|
+
api_url: Optional[str] = Field(
|
|
79
|
+
default=None,
|
|
80
|
+
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
|
|
81
|
+
)
|
|
82
|
+
|
|
77
83
|
model_config = SettingsConfigDict(
|
|
78
84
|
env_prefix="BASIC_MEMORY_",
|
|
79
85
|
extra="ignore",
|
|
@@ -124,6 +130,7 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
124
130
|
"""
|
|
125
131
|
|
|
126
132
|
# Load the app-level database path from the global config
|
|
133
|
+
config_manager = ConfigManager()
|
|
127
134
|
config = config_manager.load_config() # pragma: no cover
|
|
128
135
|
return config.app_database_path # pragma: no cover
|
|
129
136
|
|
|
@@ -162,20 +169,21 @@ class ConfigManager:
|
|
|
162
169
|
# Ensure config directory exists
|
|
163
170
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
164
171
|
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
@property
|
|
173
|
+
def config(self) -> BasicMemoryConfig:
|
|
174
|
+
"""Get configuration, loading it lazily if needed."""
|
|
175
|
+
return self.load_config()
|
|
167
176
|
|
|
168
177
|
def load_config(self) -> BasicMemoryConfig:
|
|
169
178
|
"""Load configuration from file or create default."""
|
|
179
|
+
|
|
170
180
|
if self.config_file.exists():
|
|
171
181
|
try:
|
|
172
182
|
data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
173
183
|
return BasicMemoryConfig(**data)
|
|
174
184
|
except Exception as e: # pragma: no cover
|
|
175
|
-
logger.
|
|
176
|
-
|
|
177
|
-
self.save_config(config)
|
|
178
|
-
return config
|
|
185
|
+
logger.exception(f"Failed to load config: {e}")
|
|
186
|
+
raise e
|
|
179
187
|
else:
|
|
180
188
|
config = BasicMemoryConfig()
|
|
181
189
|
self.save_config(config)
|
|
@@ -183,10 +191,7 @@ class ConfigManager:
|
|
|
183
191
|
|
|
184
192
|
def save_config(self, config: BasicMemoryConfig) -> None:
|
|
185
193
|
"""Save configuration to file."""
|
|
186
|
-
|
|
187
|
-
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
|
|
188
|
-
except Exception as e: # pragma: no cover
|
|
189
|
-
logger.error(f"Failed to save config: {e}")
|
|
194
|
+
save_basic_memory_config(self.config_file, config)
|
|
190
195
|
|
|
191
196
|
@property
|
|
192
197
|
def projects(self) -> Dict[str, str]:
|
|
@@ -208,8 +213,10 @@ class ConfigManager:
|
|
|
208
213
|
project_path = Path(path)
|
|
209
214
|
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
|
210
215
|
|
|
211
|
-
|
|
212
|
-
self.
|
|
216
|
+
# Load config, modify it, and save it
|
|
217
|
+
config = self.load_config()
|
|
218
|
+
config.projects[name] = str(project_path)
|
|
219
|
+
self.save_config(config)
|
|
213
220
|
return ProjectConfig(name=name, home=project_path)
|
|
214
221
|
|
|
215
222
|
def remove_project(self, name: str) -> None:
|
|
@@ -219,11 +226,13 @@ class ConfigManager:
|
|
|
219
226
|
if not project_name: # pragma: no cover
|
|
220
227
|
raise ValueError(f"Project '{name}' not found")
|
|
221
228
|
|
|
222
|
-
|
|
229
|
+
# Load config, check, modify, and save
|
|
230
|
+
config = self.load_config()
|
|
231
|
+
if project_name == config.default_project: # pragma: no cover
|
|
223
232
|
raise ValueError(f"Cannot remove the default project '{name}'")
|
|
224
233
|
|
|
225
|
-
del
|
|
226
|
-
self.save_config(
|
|
234
|
+
del config.projects[name]
|
|
235
|
+
self.save_config(config)
|
|
227
236
|
|
|
228
237
|
def set_default_project(self, name: str) -> None:
|
|
229
238
|
"""Set the default project."""
|
|
@@ -231,15 +240,18 @@ class ConfigManager:
|
|
|
231
240
|
if not project_name: # pragma: no cover
|
|
232
241
|
raise ValueError(f"Project '{name}' not found")
|
|
233
242
|
|
|
234
|
-
|
|
235
|
-
self.
|
|
243
|
+
# Load config, modify, and save
|
|
244
|
+
config = self.load_config()
|
|
245
|
+
config.default_project = name
|
|
246
|
+
self.save_config(config)
|
|
236
247
|
|
|
237
248
|
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
|
|
238
249
|
"""Look up a project from the configuration by name or permalink"""
|
|
239
250
|
project_permalink = generate_permalink(name)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
app_config = self.config
|
|
252
|
+
for project_name, path in app_config.projects.items():
|
|
253
|
+
if project_permalink == generate_permalink(project_name):
|
|
254
|
+
return project_name, path
|
|
243
255
|
return None, None
|
|
244
256
|
|
|
245
257
|
|
|
@@ -252,7 +264,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
|
|
252
264
|
actual_project_name = None
|
|
253
265
|
|
|
254
266
|
# load the config from file
|
|
255
|
-
|
|
267
|
+
config_manager = ConfigManager()
|
|
256
268
|
app_config = config_manager.load_config()
|
|
257
269
|
|
|
258
270
|
# Get project name from environment variable
|
|
@@ -282,14 +294,12 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
|
|
282
294
|
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
|
283
295
|
|
|
284
296
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
# Load project config for the default project (backward compatibility)
|
|
292
|
-
config: ProjectConfig = get_project_config()
|
|
297
|
+
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
|
|
298
|
+
"""Save configuration to file."""
|
|
299
|
+
try:
|
|
300
|
+
file_path.write_text(json.dumps(config.model_dump(), indent=2))
|
|
301
|
+
except Exception as e: # pragma: no cover
|
|
302
|
+
logger.error(f"Failed to save config: {e}")
|
|
293
303
|
|
|
294
304
|
|
|
295
305
|
def update_current_project(project_name: str) -> None:
|
|
@@ -341,12 +351,17 @@ def setup_basic_memory_logging(): # pragma: no cover
|
|
|
341
351
|
# print("Skipping duplicate logging setup")
|
|
342
352
|
return
|
|
343
353
|
|
|
354
|
+
# Check for console logging environment variable
|
|
355
|
+
console_logging = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower() == "true"
|
|
356
|
+
|
|
357
|
+
config_manager = ConfigManager()
|
|
358
|
+
config = get_project_config()
|
|
344
359
|
setup_logging(
|
|
345
360
|
env=config_manager.config.env,
|
|
346
361
|
home_dir=user_home, # Use user home for logs
|
|
347
|
-
log_level=config_manager.
|
|
362
|
+
log_level=config_manager.config.log_level,
|
|
348
363
|
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
|
349
|
-
console=
|
|
364
|
+
console=console_logging,
|
|
350
365
|
)
|
|
351
366
|
|
|
352
367
|
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
basic_memory/db.py
CHANGED
|
@@ -4,7 +4,7 @@ from enum import Enum, auto
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import AsyncGenerator, Optional
|
|
6
6
|
|
|
7
|
-
from basic_memory.config import BasicMemoryConfig
|
|
7
|
+
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
|
8
8
|
from alembic import command
|
|
9
9
|
from alembic.config import Config
|
|
10
10
|
|
|
@@ -88,7 +88,6 @@ async def get_or_create_db(
|
|
|
88
88
|
db_path: Path,
|
|
89
89
|
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
|
90
90
|
ensure_migrations: bool = True,
|
|
91
|
-
app_config: Optional["BasicMemoryConfig"] = None,
|
|
92
91
|
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
|
93
92
|
"""Get or create database engine and session maker."""
|
|
94
93
|
global _engine, _session_maker
|
|
@@ -98,10 +97,7 @@ async def get_or_create_db(
|
|
|
98
97
|
|
|
99
98
|
# Run migrations automatically unless explicitly disabled
|
|
100
99
|
if ensure_migrations:
|
|
101
|
-
|
|
102
|
-
from basic_memory.config import app_config as global_app_config
|
|
103
|
-
|
|
104
|
-
app_config = global_app_config
|
|
100
|
+
app_config = ConfigManager().config
|
|
105
101
|
await run_migrations(app_config, db_type)
|
|
106
102
|
|
|
107
103
|
# These checks should never fail since we just created the engine and session maker
|
basic_memory/deps.py
CHANGED
|
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import (
|
|
|
12
12
|
import pathlib
|
|
13
13
|
|
|
14
14
|
from basic_memory import db
|
|
15
|
-
from basic_memory.config import ProjectConfig, BasicMemoryConfig
|
|
15
|
+
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
|
|
16
16
|
from basic_memory.importers import (
|
|
17
17
|
ChatGPTImporter,
|
|
18
18
|
ClaudeConversationsImporter,
|
|
@@ -33,10 +33,10 @@ from basic_memory.services.file_service import FileService
|
|
|
33
33
|
from basic_memory.services.link_resolver import LinkResolver
|
|
34
34
|
from basic_memory.services.search_service import SearchService
|
|
35
35
|
from basic_memory.sync import SyncService
|
|
36
|
-
from basic_memory.config import app_config
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
|
39
|
+
app_config = ConfigManager().config
|
|
40
40
|
return app_config
|
|
41
41
|
|
|
42
42
|
|
|
@@ -297,6 +297,7 @@ ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
|
|
297
297
|
|
|
298
298
|
|
|
299
299
|
async def get_sync_service(
|
|
300
|
+
app_config: AppConfigDep,
|
|
300
301
|
entity_service: EntityServiceDep,
|
|
301
302
|
entity_parser: EntityParserDep,
|
|
302
303
|
entity_repository: EntityRepositoryDep,
|
|
@@ -193,7 +193,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
|
|
193
193
|
def _traverse_messages(
|
|
194
194
|
self, mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
|
|
195
195
|
) -> List[Dict[str, Any]]: # pragma: no cover
|
|
196
|
-
"""Traverse message tree
|
|
196
|
+
"""Traverse message tree iteratively to handle deep conversations.
|
|
197
197
|
|
|
198
198
|
Args:
|
|
199
199
|
mapping: Message mapping.
|
|
@@ -204,19 +204,29 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
|
|
204
204
|
List of message data.
|
|
205
205
|
"""
|
|
206
206
|
messages = []
|
|
207
|
-
|
|
207
|
+
if not root_id:
|
|
208
|
+
return messages
|
|
208
209
|
|
|
209
|
-
|
|
210
|
+
# Use iterative approach with stack to avoid recursion depth issues
|
|
211
|
+
stack = [root_id]
|
|
212
|
+
|
|
213
|
+
while stack:
|
|
214
|
+
node_id = stack.pop()
|
|
215
|
+
if not node_id:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
node = mapping.get(node_id)
|
|
219
|
+
if not node:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Process current node if it has a message and hasn't been seen
|
|
210
223
|
if node["id"] not in seen and node.get("message"):
|
|
211
224
|
seen.add(node["id"])
|
|
212
225
|
messages.append(node["message"])
|
|
213
226
|
|
|
214
|
-
#
|
|
227
|
+
# Add children to stack in reverse order to maintain conversation flow
|
|
215
228
|
children = node.get("children", [])
|
|
216
|
-
for child_id in children:
|
|
217
|
-
|
|
218
|
-
messages.extend(child_msgs)
|
|
219
|
-
|
|
220
|
-
break # Don't follow siblings
|
|
229
|
+
for child_id in reversed(children):
|
|
230
|
+
stack.append(child_id)
|
|
221
231
|
|
|
222
232
|
return messages
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Any, Dict, List
|
|
5
5
|
|
|
6
|
-
from basic_memory.config import
|
|
6
|
+
from basic_memory.config import get_project_config
|
|
7
7
|
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
|
|
8
8
|
from basic_memory.importers.base import Importer
|
|
9
9
|
from basic_memory.schemas.importer import EntityImportResult
|
|
@@ -27,10 +27,12 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
27
27
|
Returns:
|
|
28
28
|
EntityImportResult containing statistics and status of the import.
|
|
29
29
|
"""
|
|
30
|
+
config = get_project_config()
|
|
30
31
|
try:
|
|
31
32
|
# First pass - collect all relations by source entity
|
|
32
33
|
entity_relations: Dict[str, List[Relation]] = {}
|
|
33
34
|
entities: Dict[str, Dict[str, Any]] = {}
|
|
35
|
+
skipped_entities: int = 0
|
|
34
36
|
|
|
35
37
|
# Ensure the base path exists
|
|
36
38
|
base_path = config.home # pragma: no cover
|
|
@@ -41,7 +43,13 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
41
43
|
for line in source_data:
|
|
42
44
|
data = line
|
|
43
45
|
if data["type"] == "entity":
|
|
44
|
-
|
|
46
|
+
# Handle different possible name keys
|
|
47
|
+
entity_name = data.get("name") or data.get("entityName") or data.get("id")
|
|
48
|
+
if not entity_name:
|
|
49
|
+
logger.warning(f"Entity missing name field: {data}")
|
|
50
|
+
skipped_entities += 1
|
|
51
|
+
continue
|
|
52
|
+
entities[entity_name] = data
|
|
45
53
|
elif data["type"] == "relation":
|
|
46
54
|
# Store relation with its source entity
|
|
47
55
|
source = data.get("from") or data.get("from_id")
|
|
@@ -57,25 +65,31 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
57
65
|
# Second pass - create and write entities
|
|
58
66
|
entities_created = 0
|
|
59
67
|
for name, entity_data in entities.items():
|
|
68
|
+
# Get entity type with fallback
|
|
69
|
+
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
|
70
|
+
|
|
60
71
|
# Ensure entity type directory exists
|
|
61
|
-
entity_type_dir = base_path /
|
|
72
|
+
entity_type_dir = base_path / entity_type
|
|
62
73
|
entity_type_dir.mkdir(parents=True, exist_ok=True)
|
|
63
74
|
|
|
75
|
+
# Get observations with fallback to empty list
|
|
76
|
+
observations = entity_data.get("observations", [])
|
|
77
|
+
|
|
64
78
|
entity = EntityMarkdown(
|
|
65
79
|
frontmatter=EntityFrontmatter(
|
|
66
80
|
metadata={
|
|
67
|
-
"type":
|
|
81
|
+
"type": entity_type,
|
|
68
82
|
"title": name,
|
|
69
|
-
"permalink": f"{
|
|
83
|
+
"permalink": f"{entity_type}/{name}",
|
|
70
84
|
}
|
|
71
85
|
),
|
|
72
86
|
content=f"# {name}\n",
|
|
73
|
-
observations=[Observation(content=obs) for obs in
|
|
87
|
+
observations=[Observation(content=obs) for obs in observations],
|
|
74
88
|
relations=entity_relations.get(name, []),
|
|
75
89
|
)
|
|
76
90
|
|
|
77
91
|
# Write entity file
|
|
78
|
-
file_path = base_path / f"{
|
|
92
|
+
file_path = base_path / f"{entity_type}/{name}.md"
|
|
79
93
|
await self.write_entity(entity, file_path)
|
|
80
94
|
entities_created += 1
|
|
81
95
|
|
|
@@ -86,6 +100,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
86
100
|
success=True,
|
|
87
101
|
entities=entities_created,
|
|
88
102
|
relations=relations_count,
|
|
103
|
+
skipped_entities=skipped_entities,
|
|
89
104
|
)
|
|
90
105
|
|
|
91
106
|
except Exception as e: # pragma: no cover
|
basic_memory/mcp/async_client.py
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
from httpx import ASGITransport, AsyncClient
|
|
2
|
+
from loguru import logger
|
|
2
3
|
|
|
3
4
|
from basic_memory.api.app import app as fastapi_app
|
|
5
|
+
from basic_memory.config import ConfigManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_client() -> AsyncClient:
|
|
9
|
+
"""Create an HTTP client based on configuration.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
AsyncClient configured for either local ASGI or remote HTTP transport
|
|
13
|
+
"""
|
|
14
|
+
config_manager = ConfigManager()
|
|
15
|
+
config = config_manager.load_config()
|
|
16
|
+
|
|
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)
|
|
21
|
+
else:
|
|
22
|
+
# Use ASGI transport for local API
|
|
23
|
+
logger.debug("Creating ASGI client for local Basic Memory API")
|
|
24
|
+
return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
|
|
4
25
|
|
|
5
|
-
BASE_URL = "http://test"
|
|
6
26
|
|
|
7
27
|
# Create shared async client
|
|
8
|
-
client =
|
|
28
|
+
client = create_client()
|
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from typing import Optional
|
|
9
9
|
from loguru import logger
|
|
10
10
|
|
|
11
|
-
from basic_memory.config import ProjectConfig, get_project_config,
|
|
11
|
+
from basic_memory.config import ProjectConfig, get_project_config, ConfigManager
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@dataclass
|
|
@@ -23,7 +23,7 @@ class ProjectSession:
|
|
|
23
23
|
current_project: Optional[str] = None
|
|
24
24
|
default_project: Optional[str] = None
|
|
25
25
|
|
|
26
|
-
def initialize(self, default_project: str) ->
|
|
26
|
+
def initialize(self, default_project: str) -> "ProjectSession":
|
|
27
27
|
"""Set the default project from config on startup.
|
|
28
28
|
|
|
29
29
|
Args:
|
|
@@ -32,6 +32,7 @@ class ProjectSession:
|
|
|
32
32
|
self.default_project = default_project
|
|
33
33
|
self.current_project = default_project
|
|
34
34
|
logger.info(f"Initialized project session with default project: {default_project}")
|
|
35
|
+
return self
|
|
35
36
|
|
|
36
37
|
def get_current_project(self) -> str:
|
|
37
38
|
"""Get the currently active project name.
|
|
@@ -72,7 +73,7 @@ class ProjectSession:
|
|
|
72
73
|
via CLI or API to ensure MCP session stays in sync.
|
|
73
74
|
"""
|
|
74
75
|
# Reload config to get latest default project
|
|
75
|
-
current_config =
|
|
76
|
+
current_config = ConfigManager().config
|
|
76
77
|
new_default = current_config.default_project
|
|
77
78
|
|
|
78
79
|
# Reinitialize with new default
|
|
@@ -102,7 +103,8 @@ def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
|
|
|
102
103
|
return project
|
|
103
104
|
|
|
104
105
|
current_project = session.get_current_project()
|
|
105
|
-
|
|
106
|
+
active_project = get_project_config(current_project)
|
|
107
|
+
return active_project
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
def add_project_metadata(result: str, project_name: str) -> str:
|
|
@@ -10,12 +10,10 @@ from basic_memory.mcp.prompts import continue_conversation
|
|
|
10
10
|
from basic_memory.mcp.prompts import recent_activity
|
|
11
11
|
from basic_memory.mcp.prompts import search
|
|
12
12
|
from basic_memory.mcp.prompts import ai_assistant_guide
|
|
13
|
-
from basic_memory.mcp.prompts import sync_status
|
|
14
13
|
|
|
15
14
|
__all__ = [
|
|
16
15
|
"ai_assistant_guide",
|
|
17
16
|
"continue_conversation",
|
|
18
17
|
"recent_activity",
|
|
19
18
|
"search",
|
|
20
|
-
"sync_status",
|
|
21
19
|
]
|
basic_memory/mcp/server.py
CHANGED
|
@@ -7,25 +7,10 @@ from contextlib import asynccontextmanager
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from typing import AsyncIterator, Optional, Any
|
|
9
9
|
|
|
10
|
-
from dotenv import load_dotenv
|
|
11
10
|
from fastmcp import FastMCP
|
|
12
|
-
from fastmcp.utilities.logging import configure_logging as mcp_configure_logging
|
|
13
|
-
from mcp.server.auth.settings import AuthSettings
|
|
14
11
|
|
|
15
|
-
from basic_memory.config import
|
|
12
|
+
from basic_memory.config import ConfigManager
|
|
16
13
|
from basic_memory.services.initialization import initialize_app
|
|
17
|
-
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
|
|
18
|
-
from basic_memory.mcp.project_session import session
|
|
19
|
-
from basic_memory.mcp.external_auth_provider import (
|
|
20
|
-
create_github_provider,
|
|
21
|
-
create_google_provider,
|
|
22
|
-
)
|
|
23
|
-
from basic_memory.mcp.supabase_auth_provider import SupabaseOAuthProvider
|
|
24
|
-
|
|
25
|
-
# mcp console logging
|
|
26
|
-
mcp_configure_logging(level="ERROR")
|
|
27
|
-
|
|
28
|
-
load_dotenv()
|
|
29
14
|
|
|
30
15
|
|
|
31
16
|
@dataclass
|
|
@@ -36,7 +21,11 @@ class AppContext:
|
|
|
36
21
|
|
|
37
22
|
@asynccontextmanager
|
|
38
23
|
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
|
|
39
|
-
"""
|
|
24
|
+
""" """
|
|
25
|
+
# defer import so tests can monkeypatch
|
|
26
|
+
from basic_memory.mcp.project_session import session
|
|
27
|
+
|
|
28
|
+
app_config = ConfigManager().config
|
|
40
29
|
# Initialize on startup (now returns migration_manager)
|
|
41
30
|
migration_manager = await initialize_app(app_config)
|
|
42
31
|
|
|
@@ -50,60 +39,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
|
|
|
50
39
|
pass
|
|
51
40
|
|
|
52
41
|
|
|
53
|
-
#
|
|
54
|
-
def create_auth_config() -> tuple[AuthSettings | None, Any | None]:
|
|
55
|
-
"""Create OAuth configuration if enabled."""
|
|
56
|
-
# Check if OAuth is enabled via environment variable
|
|
57
|
-
import os
|
|
58
|
-
|
|
59
|
-
if os.getenv("FASTMCP_AUTH_ENABLED", "false").lower() == "true":
|
|
60
|
-
from pydantic import AnyHttpUrl
|
|
61
|
-
|
|
62
|
-
# Configure OAuth settings
|
|
63
|
-
issuer_url = os.getenv("FASTMCP_AUTH_ISSUER_URL", "http://localhost:8000")
|
|
64
|
-
required_scopes = os.getenv("FASTMCP_AUTH_REQUIRED_SCOPES", "read,write")
|
|
65
|
-
docs_url = os.getenv("FASTMCP_AUTH_DOCS_URL") or "http://localhost:8000/docs/oauth"
|
|
66
|
-
|
|
67
|
-
auth_settings = AuthSettings(
|
|
68
|
-
issuer_url=AnyHttpUrl(issuer_url),
|
|
69
|
-
service_documentation_url=AnyHttpUrl(docs_url),
|
|
70
|
-
required_scopes=required_scopes.split(",") if required_scopes else ["read", "write"],
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
# Create OAuth provider based on type
|
|
74
|
-
provider_type = os.getenv("FASTMCP_AUTH_PROVIDER", "basic").lower()
|
|
75
|
-
|
|
76
|
-
if provider_type == "github":
|
|
77
|
-
auth_provider = create_github_provider()
|
|
78
|
-
elif provider_type == "google":
|
|
79
|
-
auth_provider = create_google_provider()
|
|
80
|
-
elif provider_type == "supabase":
|
|
81
|
-
supabase_url = os.getenv("SUPABASE_URL")
|
|
82
|
-
supabase_anon_key = os.getenv("SUPABASE_ANON_KEY")
|
|
83
|
-
supabase_service_key = os.getenv("SUPABASE_SERVICE_KEY")
|
|
84
|
-
|
|
85
|
-
if not supabase_url or not supabase_anon_key:
|
|
86
|
-
raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY must be set for Supabase auth")
|
|
87
|
-
|
|
88
|
-
auth_provider = SupabaseOAuthProvider(
|
|
89
|
-
supabase_url=supabase_url,
|
|
90
|
-
supabase_anon_key=supabase_anon_key,
|
|
91
|
-
supabase_service_key=supabase_service_key,
|
|
92
|
-
issuer_url=issuer_url,
|
|
93
|
-
)
|
|
94
|
-
else: # default to "basic"
|
|
95
|
-
auth_provider = BasicMemoryOAuthProvider(issuer_url=issuer_url)
|
|
96
|
-
|
|
97
|
-
return auth_settings, auth_provider
|
|
98
|
-
|
|
99
|
-
return None, None
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# Create auth configuration
|
|
103
|
-
auth_settings, auth_provider = create_auth_config()
|
|
104
|
-
|
|
105
|
-
# Create the shared server instance
|
|
42
|
+
# Create the shared server instance with custom Stytch auth
|
|
106
43
|
mcp = FastMCP(
|
|
107
44
|
name="Basic Memory",
|
|
108
|
-
|
|
45
|
+
lifespan=app_lifespan,
|
|
109
46
|
)
|
|
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.utils import call_post, call_get
|
|
|
11
11
|
from basic_memory.mcp.project_session import get_active_project
|
|
12
12
|
from basic_memory.schemas import EntityResponse
|
|
13
13
|
from basic_memory.schemas.project_info import ProjectList
|
|
14
|
+
from basic_memory.utils import validate_project_path
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
async def _detect_cross_project_move_attempt(
|
|
@@ -47,18 +48,7 @@ async def _detect_cross_project_move_attempt(
|
|
|
47
48
|
identifier, destination_path, current_project, matching_project
|
|
48
49
|
)
|
|
49
50
|
|
|
50
|
-
#
|
|
51
|
-
# (e.g., contains common project-like patterns)
|
|
52
|
-
if any(keyword in dest_lower for keyword in ["project", "workspace", "repo"]):
|
|
53
|
-
# This might be a cross-project attempt, but we can't be sure
|
|
54
|
-
# Return a general guidance message
|
|
55
|
-
available_projects = [
|
|
56
|
-
p.name for p in project_list.projects if p.name != current_project
|
|
57
|
-
]
|
|
58
|
-
if available_projects:
|
|
59
|
-
return _format_potential_cross_project_guidance(
|
|
60
|
-
identifier, destination_path, current_project, available_projects
|
|
61
|
-
)
|
|
51
|
+
# No other cross-project patterns detected
|
|
62
52
|
|
|
63
53
|
except Exception as e:
|
|
64
54
|
# If we can't detect, don't interfere with normal error handling
|
|
@@ -404,6 +394,28 @@ async def move_note(
|
|
|
404
394
|
active_project = get_active_project(project)
|
|
405
395
|
project_url = active_project.project_url
|
|
406
396
|
|
|
397
|
+
# Validate destination path to prevent path traversal attacks
|
|
398
|
+
project_path = active_project.home
|
|
399
|
+
if not validate_project_path(destination_path, project_path):
|
|
400
|
+
logger.warning(
|
|
401
|
+
"Attempted path traversal attack blocked",
|
|
402
|
+
destination_path=destination_path,
|
|
403
|
+
project=active_project.name,
|
|
404
|
+
)
|
|
405
|
+
return f"""# Move Failed - Security Validation Error
|
|
406
|
+
|
|
407
|
+
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
|
|
408
|
+
|
|
409
|
+
## Valid path examples:
|
|
410
|
+
- `notes/my-file.md`
|
|
411
|
+
- `projects/2025/meeting-notes.md`
|
|
412
|
+
- `archive/old-notes.md`
|
|
413
|
+
|
|
414
|
+
## Try again with a safe path:
|
|
415
|
+
```
|
|
416
|
+
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
|
417
|
+
```"""
|
|
418
|
+
|
|
407
419
|
# Check for potential cross-project move attempts
|
|
408
420
|
cross_project_error = await _detect_cross_project_move_attempt(
|
|
409
421
|
identifier, destination_path, active_project.name
|
|
@@ -17,6 +17,7 @@ from basic_memory.mcp.async_client import client
|
|
|
17
17
|
from basic_memory.mcp.tools.utils import call_get
|
|
18
18
|
from basic_memory.mcp.project_session import get_active_project
|
|
19
19
|
from basic_memory.schemas.memory import memory_url_path
|
|
20
|
+
from basic_memory.utils import validate_project_path
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def calculate_target_params(content_length):
|
|
@@ -188,6 +189,21 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
|
|
|
188
189
|
project_url = active_project.project_url
|
|
189
190
|
|
|
190
191
|
url = memory_url_path(path)
|
|
192
|
+
|
|
193
|
+
# Validate path to prevent path traversal attacks
|
|
194
|
+
project_path = active_project.home
|
|
195
|
+
if not validate_project_path(url, project_path):
|
|
196
|
+
logger.warning(
|
|
197
|
+
"Attempted path traversal attack blocked",
|
|
198
|
+
path=path,
|
|
199
|
+
url=url,
|
|
200
|
+
project=active_project.name,
|
|
201
|
+
)
|
|
202
|
+
return {
|
|
203
|
+
"type": "error",
|
|
204
|
+
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
response = await call_get(client, f"{project_url}/resource/{url}")
|
|
192
208
|
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
193
209
|
content_length = int(response.headers.get("content-length", 0))
|
|
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.search import search_notes
|
|
|
11
11
|
from basic_memory.mcp.tools.utils import call_get
|
|
12
12
|
from basic_memory.mcp.project_session import get_active_project
|
|
13
13
|
from basic_memory.schemas.memory import memory_url_path
|
|
14
|
+
from basic_memory.utils import validate_project_path
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@mcp.tool(
|
|
@@ -67,6 +68,17 @@ async def read_note(
|
|
|
67
68
|
|
|
68
69
|
# Get the file via REST API - first try direct permalink lookup
|
|
69
70
|
entity_path = memory_url_path(identifier)
|
|
71
|
+
|
|
72
|
+
# Validate path to prevent path traversal attacks
|
|
73
|
+
project_path = active_project.home
|
|
74
|
+
if not validate_project_path(entity_path, project_path):
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Attempted path traversal attack blocked",
|
|
77
|
+
identifier=identifier,
|
|
78
|
+
entity_path=entity_path,
|
|
79
|
+
project=active_project.name,
|
|
80
|
+
)
|
|
81
|
+
return f"# Error\n\nPath '{identifier}' is not allowed - paths must stay within project boundaries"
|
|
70
82
|
path = f"{project_url}/resource/{entity_path}"
|
|
71
83
|
logger.info(f"Attempting to read note from URL: {path}")
|
|
72
84
|
|