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.

Files changed (51) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +3 -1
  3. basic_memory/api/app.py +4 -1
  4. basic_memory/api/routers/management_router.py +3 -1
  5. basic_memory/api/routers/project_router.py +21 -13
  6. basic_memory/cli/app.py +3 -3
  7. basic_memory/cli/commands/__init__.py +1 -2
  8. basic_memory/cli/commands/db.py +5 -5
  9. basic_memory/cli/commands/import_chatgpt.py +3 -2
  10. basic_memory/cli/commands/import_claude_conversations.py +3 -1
  11. basic_memory/cli/commands/import_claude_projects.py +3 -1
  12. basic_memory/cli/commands/import_memory_json.py +5 -2
  13. basic_memory/cli/commands/mcp.py +3 -15
  14. basic_memory/cli/commands/project.py +41 -0
  15. basic_memory/cli/commands/status.py +4 -1
  16. basic_memory/cli/commands/sync.py +10 -2
  17. basic_memory/cli/main.py +0 -1
  18. basic_memory/config.py +46 -31
  19. basic_memory/db.py +2 -6
  20. basic_memory/deps.py +3 -2
  21. basic_memory/importers/chatgpt_importer.py +19 -9
  22. basic_memory/importers/memory_json_importer.py +22 -7
  23. basic_memory/mcp/async_client.py +22 -2
  24. basic_memory/mcp/project_session.py +6 -4
  25. basic_memory/mcp/prompts/__init__.py +0 -2
  26. basic_memory/mcp/server.py +8 -71
  27. basic_memory/mcp/tools/move_note.py +24 -12
  28. basic_memory/mcp/tools/read_content.py +16 -0
  29. basic_memory/mcp/tools/read_note.py +12 -0
  30. basic_memory/mcp/tools/sync_status.py +3 -2
  31. basic_memory/mcp/tools/write_note.py +9 -1
  32. basic_memory/models/project.py +3 -3
  33. basic_memory/repository/project_repository.py +18 -0
  34. basic_memory/schemas/importer.py +1 -0
  35. basic_memory/services/entity_service.py +49 -3
  36. basic_memory/services/initialization.py +0 -75
  37. basic_memory/services/project_service.py +85 -28
  38. basic_memory/sync/background_sync.py +4 -3
  39. basic_memory/sync/sync_service.py +50 -1
  40. basic_memory/utils.py +105 -4
  41. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/METADATA +2 -2
  42. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/RECORD +45 -51
  43. basic_memory/cli/commands/auth.py +0 -136
  44. basic_memory/mcp/auth_provider.py +0 -270
  45. basic_memory/mcp/external_auth_provider.py +0 -321
  46. basic_memory/mcp/prompts/sync_status.py +0 -112
  47. basic_memory/mcp/supabase_auth_provider.py +0 -463
  48. basic_memory/services/migration_service.py +0 -168
  49. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/WHEEL +0 -0
  50. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/entry_points.txt +0 -0
  51. {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
- # Load or create configuration
166
- self.config = self.load_config()
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.error(f"Failed to load config: {e}")
176
- config = BasicMemoryConfig()
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
- try:
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
- self.config.projects[name] = str(project_path)
212
- self.save_config(self.config)
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
- if project_name == self.config.default_project: # pragma: no cover
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 self.config.projects[name]
226
- self.save_config(self.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
- self.config.default_project = name
235
- self.save_config(self.config)
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
- for name, path in app_config.projects.items():
241
- if project_permalink == generate_permalink(name):
242
- return name, path
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
- global app_config
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
- # Create config manager
286
- config_manager = ConfigManager()
287
-
288
- # Export the app-level config
289
- app_config: BasicMemoryConfig = config_manager.config
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.load_config().log_level,
362
+ log_level=config_manager.config.log_level,
348
363
  log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
349
- console=False,
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
- if app_config is None:
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 and return messages in order.
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
- node = mapping.get(root_id) if root_id else None
207
+ if not root_id:
208
+ return messages
208
209
 
209
- while node:
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
- # Follow children
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
- child_msgs = self._traverse_messages(mapping, child_id, seen)
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 config
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
- entities[data["name"]] = data
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 / entity_data["entityType"]
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": entity_data["entityType"],
81
+ "type": entity_type,
68
82
  "title": name,
69
- "permalink": f"{entity_data['entityType']}/{name}",
83
+ "permalink": f"{entity_type}/{name}",
70
84
  }
71
85
  ),
72
86
  content=f"# {name}\n",
73
- observations=[Observation(content=obs) for obs in entity_data["observations"]],
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"{entity_data['entityType']}/{name}.md"
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
@@ -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 = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
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, config_manager
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) -> None:
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 = config_manager.load_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
- return get_project_config(current_project)
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
  ]
@@ -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 app_config
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
- """Manage application lifecycle with type-safe context"""
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
- # OAuth configuration function
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
- auth=auth_provider,
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
- # Check if the destination path looks like it might be trying to reference another project
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