basic-memory 0.14.4__py3-none-any.whl → 0.15.1__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 (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -4,242 +4,76 @@ These tools allow users to switch between projects, list available projects,
4
4
  and manage project context during conversations.
5
5
  """
6
6
 
7
- from textwrap import dedent
8
- from typing import Optional
9
-
7
+ import os
10
8
  from fastmcp import Context
11
- from loguru import logger
12
9
 
13
- from basic_memory.mcp.async_client import client
14
- from basic_memory.mcp.project_session import session, add_project_metadata
10
+ from basic_memory.mcp.async_client import get_client
15
11
  from basic_memory.mcp.server import mcp
16
- from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete
17
- from basic_memory.schemas import ProjectInfoResponse
18
- from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest
12
+ from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
13
+ from basic_memory.schemas.project_info import (
14
+ ProjectList,
15
+ ProjectStatusResponse,
16
+ ProjectInfoRequest,
17
+ )
19
18
  from basic_memory.utils import generate_permalink
20
19
 
21
20
 
22
21
  @mcp.tool("list_memory_projects")
23
- async def list_memory_projects(
24
- ctx: Context | None = None, _compatibility: Optional[str] = None
25
- ) -> str:
22
+ async def list_memory_projects(context: Context | None = None) -> str:
26
23
  """List all available projects with their status.
27
24
 
28
- Shows all Basic Memory projects that are available, indicating which one
29
- is currently active and which is the default.
30
-
31
- Returns:
32
- Formatted list of projects with status indicators
33
-
34
- Example:
35
- list_memory_projects()
36
- """
37
- if ctx: # pragma: no cover
38
- await ctx.info("Listing all available projects")
39
-
40
- # Get projects from API
41
- response = await call_get(client, "/projects/projects")
42
- project_list = ProjectList.model_validate(response.json())
43
-
44
- current = session.get_current_project()
45
-
46
- result = "Available projects:\n"
47
-
48
- for project in project_list.projects:
49
- indicators = []
50
- if project.name == current:
51
- indicators.append("current")
52
- if project.is_default:
53
- indicators.append("default")
25
+ Shows all Basic Memory projects that are available for MCP operations.
26
+ Use this tool to discover projects when you need to know which project to use.
54
27
 
55
- if indicators:
56
- result += f"• {project.name} ({', '.join(indicators)})\n"
57
- else:
58
- result += f"• {project.name}\n"
59
-
60
- return add_project_metadata(result, current)
61
-
62
-
63
- @mcp.tool()
64
- async def switch_project(project_name: str, ctx: Context | None = None) -> str:
65
- """Switch to a different project context.
66
-
67
- Changes the active project context for all subsequent tool calls.
68
- Shows a project summary after switching successfully.
28
+ Use this tool:
29
+ - At conversation start when project is unknown
30
+ - When user asks about available projects
31
+ - Before any operation requiring a project
69
32
 
70
- Args:
71
- project_name: Name of the project to switch to
33
+ After calling:
34
+ - Ask user which project to use
35
+ - Remember their choice for the session
72
36
 
73
37
  Returns:
74
- Confirmation message with project summary
38
+ Formatted list of projects with session management guidance
75
39
 
76
40
  Example:
77
- switch_project("work-notes")
78
- switch_project("personal-journal")
41
+ list_memory_projects()
79
42
  """
80
- if ctx: # pragma: no cover
81
- await ctx.info(f"Switching to project: {project_name}")
43
+ async with get_client() as client:
44
+ if context: # pragma: no cover
45
+ await context.info("Listing all available projects")
46
+
47
+ # Check if server is constrained to a specific project
48
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
82
49
 
83
- project_permalink = generate_permalink(project_name)
84
- current_project = session.get_current_project()
85
- try:
86
- # Validate project exists by getting project list
50
+ # Get projects from API
87
51
  response = await call_get(client, "/projects/projects")
88
52
  project_list = ProjectList.model_validate(response.json())
89
53
 
90
- # Find the project by name (case-insensitive) or permalink
91
- target_project = None
92
- for p in project_list.projects:
93
- # Match by permalink (handles case-insensitive input)
94
- if p.permalink == project_permalink:
95
- target_project = p
96
- break
97
- # Also match by name comparison (case-insensitive)
98
- if p.name.lower() == project_name.lower():
99
- target_project = p
100
- break
101
-
102
- if not target_project:
103
- available_projects = [p.name for p in project_list.projects]
104
- return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
105
-
106
- # Switch to the project using the canonical name from database
107
- canonical_name = target_project.name
108
- session.set_current_project(canonical_name)
109
- current_project = session.get_current_project()
110
-
111
- # Get project info to show summary
112
- try:
113
- current_project_permalink = generate_permalink(canonical_name)
114
- response = await call_get(
115
- client,
116
- f"/{current_project_permalink}/project/info",
117
- params={"project_name": canonical_name},
118
- )
119
- project_info = ProjectInfoResponse.model_validate(response.json())
120
-
121
- result = f"✓ Switched to {canonical_name} project\n\n"
122
- result += "Project Summary:\n"
123
- result += f"• {project_info.statistics.total_entities} entities\n"
124
- result += f"• {project_info.statistics.total_observations} observations\n"
125
- result += f"• {project_info.statistics.total_relations} relations\n"
126
-
127
- except Exception as e:
128
- # If we can't get project info, still confirm the switch
129
- logger.warning(f"Could not get project info for {canonical_name}: {e}")
130
- result = f"✓ Switched to {canonical_name} project\n\n"
131
- result += "Project summary unavailable.\n"
132
-
133
- return add_project_metadata(result, canonical_name)
134
-
135
- except Exception as e:
136
- logger.error(f"Error switching to project {project_name}: {e}")
137
- # Revert to previous project on error
138
- session.set_current_project(current_project)
139
-
140
- # Return user-friendly error message instead of raising exception
141
- return dedent(f"""
142
- # Project Switch Failed
143
-
144
- Could not switch to project '{project_name}': {str(e)}
145
-
146
- ## Current project: {current_project}
147
- Your session remains on the previous project.
148
-
149
- ## Troubleshooting:
150
- 1. **Check available projects**: Use `list_memory_projects()` to see valid project names
151
- 2. **Verify spelling**: Ensure the project name is spelled correctly
152
- 3. **Check permissions**: Verify you have access to the requested project
153
- 4. **Try again**: The error might be temporary
154
-
155
- ## Available options:
156
- - See all projects: `list_memory_projects()`
157
- - Stay on current project: `get_current_project()`
158
- - Try different project: `switch_project("correct-project-name")`
159
-
160
- If the project should exist but isn't listed, send a message to support@basicmachines.co.
161
- """).strip()
162
-
163
-
164
- @mcp.tool()
165
- async def get_current_project(
166
- ctx: Context | None = None, _compatibility: Optional[str] = None
167
- ) -> str:
168
- """Show the currently active project and basic stats.
169
-
170
- Displays which project is currently active and provides basic information
171
- about it.
172
-
173
- Returns:
174
- Current project name and basic statistics
175
-
176
- Example:
177
- get_current_project()
178
- """
179
- if ctx: # pragma: no cover
180
- await ctx.info("Getting current project information")
181
-
182
- current_project = session.get_current_project()
183
- result = f"Current project: {current_project}\n\n"
184
-
185
- # get project stats (use permalink in URL path)
186
- current_project_permalink = generate_permalink(current_project)
187
- response = await call_get(
188
- client,
189
- f"/{current_project_permalink}/project/info",
190
- params={"project_name": current_project},
191
- )
192
- project_info = ProjectInfoResponse.model_validate(response.json())
193
-
194
- result += f"• {project_info.statistics.total_entities} entities\n"
195
- result += f"• {project_info.statistics.total_observations} observations\n"
196
- result += f"• {project_info.statistics.total_relations} relations\n"
197
-
198
- default_project = session.get_default_project()
199
- if current_project != default_project:
200
- result += f"• Default project: {default_project}\n"
201
-
202
- return add_project_metadata(result, current_project)
203
-
204
-
205
- @mcp.tool()
206
- async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
207
- """Set default project in config. Requires restart to take effect.
208
-
209
- Updates the configuration to use a different default project. This change
210
- only takes effect after restarting the Basic Memory server.
211
-
212
- Args:
213
- project_name: Name of the project to set as default
214
-
215
- Returns:
216
- Confirmation message about config update
217
-
218
- Example:
219
- set_default_project("work-notes")
220
- """
221
- if ctx: # pragma: no cover
222
- await ctx.info(f"Setting default project to: {project_name}")
223
-
224
- # Call API to set default project using URL encoding for special characters
225
- from urllib.parse import quote
226
- encoded_name = quote(project_name, safe='')
227
- response = await call_put(client, f"/projects/{encoded_name}/default")
228
- status_response = ProjectStatusResponse.model_validate(response.json())
54
+ if constrained_project:
55
+ result = f"Project: {constrained_project}\n\n"
56
+ result += "Note: This MCP server is constrained to a single project.\n"
57
+ result += "All operations will automatically use this project."
58
+ else:
59
+ # Show all projects with session guidance
60
+ result = "Available projects:\n"
229
61
 
230
- result = f"✓ {status_response.message}\n\n"
231
- result += "Restart Basic Memory for this change to take effect:\n"
232
- result += "basic-memory mcp\n"
62
+ for project in project_list.projects:
63
+ result += f" {project.name}\n"
233
64
 
234
- if status_response.old_project:
235
- result += f"\nPrevious default: {status_response.old_project.name}\n"
65
+ result += "\n" + "─" * 40 + "\n"
66
+ result += "Next: Ask which project to use for this session.\n"
67
+ result += "Example: 'Which project should I use for this task?'\n\n"
68
+ result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
69
+ result += "The user can say 'switch to [project]' to change projects."
236
70
 
237
- return add_project_metadata(result, session.get_current_project())
71
+ return result
238
72
 
239
73
 
240
74
  @mcp.tool("create_memory_project")
241
75
  async def create_memory_project(
242
- project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
76
+ project_name: str, project_path: str, set_default: bool = False, context: Context | None = None
243
77
  ) -> str:
244
78
  """Create a new Basic Memory project.
245
79
 
@@ -258,39 +92,42 @@ async def create_memory_project(
258
92
  create_memory_project("my-research", "~/Documents/research")
259
93
  create_memory_project("work-notes", "/home/user/work", set_default=True)
260
94
  """
261
- if ctx: # pragma: no cover
262
- await ctx.info(f"Creating project: {project_name} at {project_path}")
263
-
264
- # Create the project request
265
- project_request = ProjectInfoRequest(
266
- name=project_name, path=project_path, set_default=set_default
267
- )
268
-
269
- # Call API to create project
270
- response = await call_post(client, "/projects/projects", json=project_request.model_dump())
271
- status_response = ProjectStatusResponse.model_validate(response.json())
95
+ async with get_client() as client:
96
+ # Check if server is constrained to a specific project
97
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
98
+ if constrained_project:
99
+ return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
100
+
101
+ if context: # pragma: no cover
102
+ await context.info(f"Creating project: {project_name} at {project_path}")
103
+
104
+ # Create the project request
105
+ project_request = ProjectInfoRequest(
106
+ name=project_name, path=project_path, set_default=set_default
107
+ )
272
108
 
273
- result = f"✓ {status_response.message}\n\n"
109
+ # Call API to create project
110
+ response = await call_post(client, "/projects/projects", json=project_request.model_dump())
111
+ status_response = ProjectStatusResponse.model_validate(response.json())
274
112
 
275
- if status_response.new_project:
276
- result += "Project Details:\n"
277
- result += f"• Name: {status_response.new_project.name}\n"
278
- result += f"• Path: {status_response.new_project.path}\n"
113
+ result = f"✓ {status_response.message}\n\n"
279
114
 
280
- if set_default:
281
- result += " Set as default project\n"
115
+ if status_response.new_project:
116
+ result += "Project Details:\n"
117
+ result += f"• Name: {status_response.new_project.name}\n"
118
+ result += f"• Path: {status_response.new_project.path}\n"
282
119
 
283
- result += "\nProject is now available for use.\n"
120
+ if set_default:
121
+ result += "• Set as default project\n"
284
122
 
285
- # If project was set as default, update session
286
- if set_default:
287
- session.set_current_project(project_name)
123
+ result += "\nProject is now available for use in tool calls.\n"
124
+ result += f"Use '{project_name}' as the project parameter in MCP tool calls.\n"
288
125
 
289
- return add_project_metadata(result, session.get_current_project())
126
+ return result
290
127
 
291
128
 
292
129
  @mcp.tool()
293
- async def delete_project(project_name: str, ctx: Context | None = None) -> str:
130
+ async def delete_project(project_name: str, context: Context | None = None) -> str:
294
131
  """Delete a Basic Memory project.
295
132
 
296
133
  Removes a project from the configuration and database. This does NOT delete
@@ -310,55 +147,54 @@ async def delete_project(project_name: str, ctx: Context | None = None) -> str:
310
147
  This action cannot be undone. The project will need to be re-added
311
148
  to access its content through Basic Memory again.
312
149
  """
313
- if ctx: # pragma: no cover
314
- await ctx.info(f"Deleting project: {project_name}")
150
+ async with get_client() as client:
151
+ # Check if server is constrained to a specific project
152
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
153
+ if constrained_project:
154
+ return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
315
155
 
316
- current_project = session.get_current_project()
156
+ if context: # pragma: no cover
157
+ await context.info(f"Deleting project: {project_name}")
317
158
 
318
- # Check if trying to delete current project
319
- if project_name == current_project:
320
- raise ValueError(
321
- f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
322
- )
159
+ # Get project info before deletion to validate it exists
160
+ response = await call_get(client, "/projects/projects")
161
+ project_list = ProjectList.model_validate(response.json())
323
162
 
324
- # Get project info before deletion to validate it exists
325
- response = await call_get(client, "/projects/projects")
326
- project_list = ProjectList.model_validate(response.json())
327
-
328
- # Find the project by name (case-insensitive) or permalink - same logic as switch_project
329
- project_permalink = generate_permalink(project_name)
330
- target_project = None
331
- for p in project_list.projects:
332
- # Match by permalink (handles case-insensitive input)
333
- if p.permalink == project_permalink:
334
- target_project = p
335
- break
336
- # Also match by name comparison (case-insensitive)
337
- if p.name.lower() == project_name.lower():
338
- target_project = p
339
- break
340
-
341
- if not target_project:
342
- available_projects = [p.name for p in project_list.projects]
343
- raise ValueError(
344
- f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
345
- )
163
+ # Find the project by name (case-insensitive) or permalink - same logic as switch_project
164
+ project_permalink = generate_permalink(project_name)
165
+ target_project = None
166
+ for p in project_list.projects:
167
+ # Match by permalink (handles case-insensitive input)
168
+ if p.permalink == project_permalink:
169
+ target_project = p
170
+ break
171
+ # Also match by name comparison (case-insensitive)
172
+ if p.name.lower() == project_name.lower():
173
+ target_project = p
174
+ break
175
+
176
+ if not target_project:
177
+ available_projects = [p.name for p in project_list.projects]
178
+ raise ValueError(
179
+ f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
180
+ )
181
+
182
+ # Call API to delete project using URL encoding for special characters
183
+ from urllib.parse import quote
346
184
 
347
- # Call API to delete project using URL encoding for special characters
348
- from urllib.parse import quote
349
- encoded_name = quote(target_project.name, safe='')
350
- response = await call_delete(client, f"/projects/{encoded_name}")
351
- status_response = ProjectStatusResponse.model_validate(response.json())
185
+ encoded_name = quote(target_project.name, safe="")
186
+ response = await call_delete(client, f"/projects/{encoded_name}")
187
+ status_response = ProjectStatusResponse.model_validate(response.json())
352
188
 
353
- result = f"✓ {status_response.message}\n\n"
189
+ result = f"✓ {status_response.message}\n\n"
354
190
 
355
- if status_response.old_project:
356
- result += "Removed project details:\n"
357
- result += f"• Name: {status_response.old_project.name}\n"
358
- if hasattr(status_response.old_project, "path"):
359
- result += f"• Path: {status_response.old_project.path}\n"
191
+ if status_response.old_project:
192
+ result += "Removed project details:\n"
193
+ result += f"• Name: {status_response.old_project.name}\n"
194
+ if hasattr(status_response.old_project, "path"):
195
+ result += f"• Path: {status_response.old_project.path}\n"
360
196
 
361
- result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
362
- result += "Re-add the project to access its content again.\n"
197
+ result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
198
+ result += "Re-add the project to access its content again.\n"
363
199
 
364
- return add_project_metadata(result, session.get_current_project())
200
+ return result
@@ -5,17 +5,19 @@ supporting various file types including text, images, and other binary files.
5
5
  Files are read directly without any knowledge graph processing.
6
6
  """
7
7
 
8
- from typing import Optional
9
8
  import base64
10
9
  import io
11
10
 
11
+ from typing import Optional
12
+
12
13
  from loguru import logger
13
14
  from PIL import Image as PILImage
15
+ from fastmcp import Context
14
16
 
17
+ from basic_memory.mcp.project_context import get_active_project
15
18
  from basic_memory.mcp.server import mcp
16
- from basic_memory.mcp.async_client import client
19
+ from basic_memory.mcp.async_client import get_client
17
20
  from basic_memory.mcp.tools.utils import call_get
18
- from basic_memory.mcp.project_session import get_active_project
19
21
  from basic_memory.schemas.memory import memory_url_path
20
22
  from basic_memory.utils import validate_project_path
21
23
 
@@ -147,11 +149,16 @@ def optimize_image(img, content_length, max_output_bytes=350000):
147
149
 
148
150
 
149
151
  @mcp.tool(description="Read a file's raw content by path or permalink")
150
- async def read_content(path: str, project: Optional[str] = None) -> dict:
152
+ async def read_content(
153
+ path: str, project: Optional[str] = None, context: Context | None = None
154
+ ) -> dict:
151
155
  """Read a file's raw content by path or permalink.
152
156
 
153
157
  This tool provides direct access to file content in the knowledge base,
154
- handling different file types appropriately:
158
+ handling different file types appropriately. Uses stateless architecture -
159
+ project parameter optional with server resolution.
160
+
161
+ Supported file types:
155
162
  - Text files (markdown, code, etc.) are returned as plain text
156
163
  - Images are automatically resized/optimized for display
157
164
  - Other binary files are returned as base64 if below size limits
@@ -161,7 +168,9 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
161
168
  - A regular file path (docs/example.md)
162
169
  - A memory URL (memory://docs/example)
163
170
  - A permalink (docs/example)
164
- project: Optional project name to read from. If not provided, uses current active project.
171
+ project: Project name to read from. Optional - server will resolve using hierarchy.
172
+ If unknown, use list_memory_projects() to discover available projects.
173
+ context: Optional FastMCP context for performance caching.
165
174
 
166
175
  Returns:
167
176
  A dictionary with the file content and metadata:
@@ -172,83 +181,91 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
172
181
 
173
182
  Examples:
174
183
  # Read a markdown file
175
- result = await read_file("docs/project-specs.md")
184
+ result = await read_content("docs/project-specs.md")
176
185
 
177
186
  # Read an image
178
- image_data = await read_file("assets/diagram.png")
187
+ image_data = await read_content("assets/diagram.png")
179
188
 
180
189
  # Read using memory URL
181
- content = await read_file("memory://docs/architecture")
190
+ content = await read_content("memory://docs/architecture")
191
+
192
+ # Read configuration file
193
+ config = await read_content("config/settings.json")
194
+
195
+ # Explicit project specification
196
+ result = await read_content("docs/project-specs.md", project="my-project")
182
197
 
183
- # Read from specific project
184
- content = await read_content("docs/example.md", project="work-project")
198
+ Raises:
199
+ HTTPError: If project doesn't exist or is inaccessible
200
+ SecurityError: If path attempts path traversal
185
201
  """
186
- logger.info("Reading file", path=path)
202
+ logger.info("Reading file", path=path, project=project)
187
203
 
188
- active_project = get_active_project(project)
189
- project_url = active_project.project_url
204
+ async with get_client() as client:
205
+ active_project = await get_active_project(client, project, context)
206
+ project_url = active_project.project_url
190
207
 
191
- url = memory_url_path(path)
208
+ url = memory_url_path(path)
192
209
 
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
-
207
- response = await call_get(client, f"{project_url}/resource/{url}")
208
- content_type = response.headers.get("content-type", "application/octet-stream")
209
- content_length = int(response.headers.get("content-length", 0))
210
-
211
- logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
212
-
213
- # Handle text or json
214
- if content_type.startswith("text/") or content_type == "application/json":
215
- logger.debug("Processing text resource")
216
- return {
217
- "type": "text",
218
- "text": response.text,
219
- "content_type": content_type,
220
- "encoding": "utf-8",
221
- }
222
-
223
- # Handle images
224
- elif content_type.startswith("image/"):
225
- logger.debug("Processing image")
226
- img = PILImage.open(io.BytesIO(response.content))
227
- img_bytes = optimize_image(img, content_length)
228
-
229
- return {
230
- "type": "image",
231
- "source": {
232
- "type": "base64",
233
- "media_type": "image/jpeg",
234
- "data": base64.b64encode(img_bytes).decode("utf-8"),
235
- },
236
- }
237
-
238
- # Handle other file types
239
- else:
240
- logger.debug(f"Processing binary resource content_type {content_type}")
241
- if content_length > 350000: # pragma: no cover
242
- logger.warning("Document too large for response", size=content_length)
210
+ # Validate path to prevent path traversal attacks
211
+ project_path = active_project.home
212
+ if not validate_project_path(url, project_path):
213
+ logger.warning(
214
+ "Attempted path traversal attack blocked",
215
+ path=path,
216
+ url=url,
217
+ project=active_project.name,
218
+ )
243
219
  return {
244
220
  "type": "error",
245
- "error": f"Document size {content_length} bytes exceeds maximum allowed size",
221
+ "error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
222
+ }
223
+
224
+ response = await call_get(client, f"{project_url}/resource/{url}")
225
+ content_type = response.headers.get("content-type", "application/octet-stream")
226
+ content_length = int(response.headers.get("content-length", 0))
227
+
228
+ logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
229
+
230
+ # Handle text or json
231
+ if content_type.startswith("text/") or content_type == "application/json":
232
+ logger.debug("Processing text resource")
233
+ return {
234
+ "type": "text",
235
+ "text": response.text,
236
+ "content_type": content_type,
237
+ "encoding": "utf-8",
238
+ }
239
+
240
+ # Handle images
241
+ elif content_type.startswith("image/"):
242
+ logger.debug("Processing image")
243
+ img = PILImage.open(io.BytesIO(response.content))
244
+ img_bytes = optimize_image(img, content_length)
245
+
246
+ return {
247
+ "type": "image",
248
+ "source": {
249
+ "type": "base64",
250
+ "media_type": "image/jpeg",
251
+ "data": base64.b64encode(img_bytes).decode("utf-8"),
252
+ },
253
+ }
254
+
255
+ # Handle other file types
256
+ else:
257
+ logger.debug(f"Processing binary resource content_type {content_type}")
258
+ if content_length > 350000: # pragma: no cover
259
+ logger.warning("Document too large for response", size=content_length)
260
+ return {
261
+ "type": "error",
262
+ "error": f"Document size {content_length} bytes exceeds maximum allowed size",
263
+ }
264
+ return {
265
+ "type": "document",
266
+ "source": {
267
+ "type": "base64",
268
+ "media_type": content_type,
269
+ "data": base64.b64encode(response.content).decode("utf-8"),
270
+ },
246
271
  }
247
- return {
248
- "type": "document",
249
- "source": {
250
- "type": "base64",
251
- "media_type": content_type,
252
- "data": base64.b64encode(response.content).decode("utf-8"),
253
- },
254
- }