Skip to content

Commit c85d096

Browse files
committed
Fix remaining MCP JSON output review issues
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 01c4892 commit c85d096

8 files changed

Lines changed: 84 additions & 46 deletions

File tree

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def _format_delete_error_response(project: str, error_message: str, identifier:
143143
- Check what notes exist: `list_directory("{project}", "/")`
144144
145145
## Need help?
146-
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
146+
If the note should be deleted but the operation keeps failing, send a message to support@basicmachines.co."""
147147

148148

149149
@mcp.tool(description="Delete a note or directory by title, permalink, or path")

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ async def edit_note(
288288
for obs in result.observations:
289289
categories[obs.category] = categories.get(obs.category, 0) + 1
290290

291-
summary.append("\\n## Observations")
291+
summary.append("\n## Observations")
292292
for category, count in sorted(categories.items()):
293293
summary.append(f"- {category}: {count}")
294294

@@ -299,7 +299,7 @@ async def edit_note(
299299
unresolved = sum(1 for r in result.relations if not r.to_id)
300300
resolved = len(result.relations) - unresolved
301301

302-
summary.append("\\n## Relations")
302+
summary.append("\n## Relations")
303303
summary.append(f"- Resolved: {resolved}")
304304
if unresolved:
305305
summary.append(f"- Unresolved: {unresolved}")

src/basic_memory/mcp/tools/move_note.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -568,17 +568,25 @@ async def move_note(
568568
# Use typed KnowledgeClient for API calls
569569
knowledge_client = KnowledgeClient(client, active_project.external_id)
570570

571-
# Get the source entity information for extension validation
571+
# Resolve once and reuse the entity ID across extension validation and move.
572572
source_ext = "md" # Default to .md if we can't determine source extension
573+
resolved_entity_id: str | None = None
574+
source_entity = None
575+
576+
async def _ensure_resolved_entity_id() -> str:
577+
"""Resolve and cache the source entity ID for the duration of this move."""
578+
nonlocal resolved_entity_id
579+
if resolved_entity_id is None:
580+
resolved_entity_id = await knowledge_client.resolve_entity(identifier)
581+
return resolved_entity_id
582+
573583
try:
574-
# Resolve identifier to entity ID
575-
entity_id = await knowledge_client.resolve_entity(identifier)
576-
# Fetch source entity information to get the current file extension
577-
source_entity = await knowledge_client.get_entity(entity_id)
584+
resolved_entity_id = await _ensure_resolved_entity_id()
585+
source_entity = await knowledge_client.get_entity(resolved_entity_id)
578586
if "." in source_entity.file_path:
579587
source_ext = source_entity.file_path.split(".")[-1]
580588
except Exception as e:
581-
# If we can't fetch the source entity, default to .md extension
589+
# If we can't fetch source metadata, continue with extension defaults.
582590
logger.debug(f"Could not fetch source entity for extension check: {e}")
583591

584592
# Validate that destination path includes a file extension
@@ -612,14 +620,15 @@ async def move_note(
612620
All examples in Basic Memory expect file extensions to be explicitly provided.
613621
""").strip()
614622

615-
# Get the source entity to check its file extension
616-
try:
617-
# Resolve identifier to entity ID (might already be cached from above)
618-
entity_id = await knowledge_client.resolve_entity(identifier)
619-
# Fetch source entity information
620-
source_entity = await knowledge_client.get_entity(entity_id)
623+
# Validate extension consistency when source metadata is available.
624+
if source_entity is None:
625+
try:
626+
resolved_entity_id = await _ensure_resolved_entity_id()
627+
source_entity = await knowledge_client.get_entity(resolved_entity_id)
628+
except Exception as e:
629+
logger.debug(f"Could not fetch source entity for extension check: {e}")
621630

622-
# Extract file extensions
631+
if source_entity is not None:
623632
source_ext = (
624633
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
625634
)
@@ -656,17 +665,13 @@ async def move_note(
656665
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
657666
```
658667
""").strip()
659-
except Exception as e:
660-
# If we can't fetch the source entity, log it but continue
661-
# This might happen if the identifier is not yet resolved
662-
logger.debug(f"Could not fetch source entity for extension check: {e}")
663668

664669
try:
665-
# Resolve identifier to entity ID for the move operation
666-
entity_id = await knowledge_client.resolve_entity(identifier)
670+
# Resolve identifier only if earlier checks could not.
671+
resolved_entity_id = await _ensure_resolved_entity_id()
667672

668673
# Call the move API using KnowledgeClient
669-
result = await knowledge_client.move_entity(entity_id, destination_path)
674+
result = await knowledge_client.move_entity(resolved_entity_id, destination_path)
670675
if output_format == "json":
671676
return {
672677
"moved": True,

src/basic_memory/mcp/tools/project_management.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ async def list_memory_projects(
4343
"name": project.name,
4444
"path": project.path,
4545
"is_default": project.is_default,
46+
# Reserved for forward-compatible cloud/private project metadata.
47+
# Local project list responses do not currently provide these values.
4648
"is_private": False,
4749
"display_name": None,
4850
}
@@ -154,7 +156,7 @@ async def create_memory_project(
154156
f"Project Details:\n"
155157
f"• Name: {existing_match.name}\n"
156158
f"• Path: {existing_match.path}\n"
157-
f"{'• Set as default project\\n' if is_default else ''}"
159+
f"{'• Set as default project\n' if is_default else ''}"
158160
"\nProject is already available for use in tool calls.\n"
159161
)
160162

src/basic_memory/mcp/tools/read_note.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -192,32 +192,33 @@ def _empty_json_payload() -> dict:
192192
"frontmatter": None,
193193
}
194194

195-
def _search_results(payload: object) -> list:
195+
def _search_results(payload: object) -> list[dict]:
196196
if isinstance(payload, dict):
197197
results = payload.get("results")
198198
return results if isinstance(results, list) else []
199-
if hasattr(payload, "results"):
200-
results = getattr(payload, "results")
201-
return results if isinstance(results, list) else []
199+
model_dump = getattr(payload, "model_dump", None)
200+
if callable(model_dump):
201+
dumped = model_dump()
202+
if isinstance(dumped, dict):
203+
results = dumped.get("results")
204+
return results if isinstance(results, list) else []
202205
return []
203206

204207
def _result_title(item: object) -> str:
205-
if isinstance(item, dict):
206-
return str(item.get("title") or "")
207-
return str(getattr(item, "title", "") or "")
208+
if not isinstance(item, dict):
209+
return ""
210+
return str(item.get("title") or "")
208211

209212
def _result_permalink(item: object) -> Optional[str]:
210-
if isinstance(item, dict):
211-
value = item.get("permalink")
212-
return str(value) if value else None
213-
value = getattr(item, "permalink", None)
213+
if not isinstance(item, dict):
214+
return None
215+
value = item.get("permalink")
214216
return str(value) if value else None
215217

216218
def _result_file_path(item: object) -> Optional[str]:
217-
if isinstance(item, dict):
218-
value = item.get("file_path")
219-
return str(value) if value else None
220-
value = getattr(item, "file_path", None)
219+
if not isinstance(item, dict):
220+
return None
221+
value = item.get("file_path")
221222
return str(value) if value else None
222223

223224
try:

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ async def recent_activity(
8080
hierarchy above. If unknown, use list_memory_projects() to discover
8181
available projects.
8282
output_format: "text" returns human-readable summary text. "json" returns
83-
a flat list of recent entity items.
83+
a flat list of recent items.
8484
context: Optional FastMCP context for performance caching.
8585
8686
Returns:
@@ -335,6 +335,7 @@ def _extract_recent_rows(
335335
for result in activity_data.results:
336336
primary = result.primary_result
337337
row = {
338+
"type": primary.type,
338339
"title": primary.title,
339340
"permalink": primary.permalink,
340341
"file_path": primary.file_path,

test-int/mcp/test_output_format_json_integration.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ async def test_recent_activity_json_output(mcp_server, app, test_project):
128128
assert isinstance(payload, list)
129129
assert any(item.get("title") == "JSON Integration Recent" for item in payload)
130130
for item in payload:
131-
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
131+
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(
132+
item.keys()
133+
)
132134

133135

134136
@pytest.mark.asyncio
@@ -171,8 +173,11 @@ async def test_recent_activity_json_output_for_relation_and_observation_types(
171173
relation_payload = _json_content(relation_result)
172174
assert isinstance(relation_payload, list)
173175
assert relation_payload
176+
assert all(item.get("type") == "relation" for item in relation_payload)
174177
for item in relation_payload:
175-
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
178+
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(
179+
item.keys()
180+
)
176181

177182
observation_result = await client.call_tool(
178183
"recent_activity",
@@ -186,8 +191,11 @@ async def test_recent_activity_json_output_for_relation_and_observation_types(
186191
observation_payload = _json_content(observation_result)
187192
assert isinstance(observation_payload, list)
188193
assert observation_payload
194+
assert all(item.get("type") == "observation" for item in observation_payload)
189195
for item in observation_payload:
190-
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
196+
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(
197+
item.keys()
198+
)
191199

192200

193201
@pytest.mark.asyncio

tests/mcp/test_tool_json_output_modes.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ async def test_recent_activity_text_and_json_modes(app, test_project):
146146
assert isinstance(json_result, list)
147147
assert any(item.get("title") == "Mode Activity Note" for item in json_result)
148148
for item in json_result:
149-
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
149+
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys())
150150

151151

152152
@pytest.mark.asyncio
@@ -176,8 +176,9 @@ async def test_recent_activity_json_preserves_relation_and_observation_types(app
176176
)
177177
assert isinstance(relation_json, list)
178178
assert relation_json
179+
assert all(item.get("type") == "relation" for item in relation_json)
179180
for item in relation_json:
180-
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
181+
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys())
181182

182183
observation_json = await recent_activity.fn(
183184
project=test_project.name,
@@ -187,8 +188,9 @@ async def test_recent_activity_json_preserves_relation_and_observation_types(app
187188
)
188189
assert isinstance(observation_json, list)
189190
assert observation_json
191+
assert all(item.get("type") == "observation" for item in observation_json)
190192
for item in observation_json:
191-
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
193+
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys())
192194

193195

194196
@pytest.mark.asyncio
@@ -225,6 +227,25 @@ async def test_list_and_create_project_text_and_json_modes(app, test_project, tm
225227
assert create_json_again["created"] is False
226228
assert create_json_again["already_exists"] is True
227229

230+
default_project_name = "mode-default-project"
231+
default_project_path = str(
232+
tmp_path.parent / (tmp_path.name + "-projects") / "mode-default-project"
233+
)
234+
await create_memory_project.fn(
235+
project_name=default_project_name,
236+
project_path=default_project_path,
237+
set_default=True,
238+
output_format="text",
239+
)
240+
241+
default_text_again = await create_memory_project.fn(
242+
project_name=default_project_name,
243+
project_path=default_project_path,
244+
output_format="text",
245+
)
246+
assert "Set as default project\\n" not in default_text_again
247+
assert "Set as default project\n" in default_text_again
248+
228249

229250
@pytest.mark.asyncio
230251
async def test_delete_note_text_and_json_modes(app, test_project):

0 commit comments

Comments
 (0)