Skip to content

Commit 01c4892

Browse files
committed
fix: return consistent json for recent activity and delete errors
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent fc54382 commit 01c4892

4 files changed

Lines changed: 167 additions & 6 deletions

File tree

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,16 @@ async def delete_note(
281281

282282
except Exception as e: # pragma: no cover
283283
logger.error(f"Directory delete failed for '{identifier}': {e}")
284+
if output_format == "json":
285+
return {
286+
"deleted": False,
287+
"is_directory": True,
288+
"identifier": identifier,
289+
"total_files": 0,
290+
"successful_deletes": 0,
291+
"failed_deletes": 0,
292+
"error": str(e),
293+
}
284294
return f"""# Directory Delete Failed
285295
286296
Error deleting directory '{identifier}': {str(e)}

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ async def recent_activity(
192192
if output_format == "json":
193193
rows: list[dict] = []
194194
for project_name, project_activity in projects_activity.items():
195-
rows.extend(_extract_recent_entity_rows(project_activity.activity, project_name))
195+
rows.extend(_extract_recent_rows(project_activity.activity, project_name))
196196
return rows
197197

198198
# Build summary stats
@@ -268,7 +268,7 @@ async def recent_activity(
268268
activity_data = GraphContext.model_validate(response.json())
269269

270270
if output_format == "json":
271-
return _extract_recent_entity_rows(activity_data)
271+
return _extract_recent_rows(activity_data)
272272

273273
# Format project-specific mode output
274274
return _format_project_output(resolved_project, activity_data, timeframe, type)
@@ -327,15 +327,13 @@ async def _get_project_activity(
327327
)
328328

329329

330-
def _extract_recent_entity_rows(
330+
def _extract_recent_rows(
331331
activity_data: GraphContext, project_name: Optional[str] = None
332332
) -> list[dict]:
333-
"""Flatten GraphContext into a list of recent entity rows."""
333+
"""Flatten GraphContext into a list of recent rows."""
334334
rows: list[dict] = []
335335
for result in activity_data.results:
336336
primary = result.primary_result
337-
if primary.type != "entity":
338-
continue
339337
row = {
340338
"title": primary.title,
341339
"permalink": primary.permalink,

test-int/mcp/test_output_format_json_integration.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import pytest
99
from fastmcp import Client
1010

11+
from basic_memory.mcp.clients.knowledge import KnowledgeClient
12+
1113

1214
def _json_content(tool_result) -> dict | list:
1315
"""Parse a FastMCP tool result content block into JSON."""
@@ -129,6 +131,65 @@ async def test_recent_activity_json_output(mcp_server, app, test_project):
129131
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
130132

131133

134+
@pytest.mark.asyncio
135+
async def test_recent_activity_json_output_for_relation_and_observation_types(
136+
mcp_server, app, test_project
137+
):
138+
async with Client(mcp_server) as client:
139+
await client.call_tool(
140+
"write_note",
141+
{
142+
"project": test_project.name,
143+
"title": "JSON Integration Type Source",
144+
"directory": "json-int",
145+
"content": (
146+
"# JSON Integration Type Source\n\n"
147+
"- [note] observation from source\n"
148+
"- links_to [[JSON Integration Type Target]]"
149+
),
150+
},
151+
)
152+
await client.call_tool(
153+
"write_note",
154+
{
155+
"project": test_project.name,
156+
"title": "JSON Integration Type Target",
157+
"directory": "json-int",
158+
"content": "# JSON Integration Type Target\n\nBody",
159+
},
160+
)
161+
162+
relation_result = await client.call_tool(
163+
"recent_activity",
164+
{
165+
"project": test_project.name,
166+
"timeframe": "7d",
167+
"type": "relation",
168+
"output_format": "json",
169+
},
170+
)
171+
relation_payload = _json_content(relation_result)
172+
assert isinstance(relation_payload, list)
173+
assert relation_payload
174+
for item in relation_payload:
175+
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
176+
177+
observation_result = await client.call_tool(
178+
"recent_activity",
179+
{
180+
"project": test_project.name,
181+
"timeframe": "7d",
182+
"type": "observation",
183+
"output_format": "json",
184+
},
185+
)
186+
observation_payload = _json_content(observation_result)
187+
assert isinstance(observation_payload, list)
188+
assert observation_payload
189+
for item in observation_payload:
190+
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
191+
192+
132193
@pytest.mark.asyncio
133194
async def test_list_memory_projects_json_output(mcp_server, app, test_project):
134195
async with Client(mcp_server) as client:
@@ -213,6 +274,33 @@ async def test_delete_note_json_output(mcp_server, app, test_project):
213274
assert payload["file_path"]
214275

215276

277+
@pytest.mark.asyncio
278+
async def test_delete_note_directory_json_output_failure_is_structured(
279+
mcp_server, app, test_project, monkeypatch
280+
):
281+
async def mock_delete_directory(self, directory: str):
282+
raise RuntimeError("simulated directory delete failure")
283+
284+
monkeypatch.setattr(KnowledgeClient, "delete_directory", mock_delete_directory)
285+
286+
async with Client(mcp_server) as client:
287+
result = await client.call_tool(
288+
"delete_note",
289+
{
290+
"project": test_project.name,
291+
"identifier": "json-int",
292+
"is_directory": True,
293+
"output_format": "json",
294+
},
295+
)
296+
297+
payload = _json_content(result)
298+
assert payload["deleted"] is False
299+
assert payload["is_directory"] is True
300+
assert payload["identifier"] == "json-int"
301+
assert "simulated directory delete failure" in payload["error"]
302+
303+
216304
@pytest.mark.asyncio
217305
async def test_move_note_json_output(mcp_server, app, test_project):
218306
async with Client(mcp_server) as client:

tests/mcp/test_tool_json_output_modes.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
from basic_memory.mcp.clients.knowledge import KnowledgeClient
910
from basic_memory.mcp.tools import (
1011
build_context,
1112
create_memory_project,
@@ -148,6 +149,48 @@ async def test_recent_activity_text_and_json_modes(app, test_project):
148149
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
149150

150151

152+
@pytest.mark.asyncio
153+
async def test_recent_activity_json_preserves_relation_and_observation_types(app, test_project):
154+
await write_note.fn(
155+
project=test_project.name,
156+
title="Activity Type Source",
157+
directory="mode-tests",
158+
content=(
159+
"# Activity Type Source\n\n"
160+
"- [note] observation from source\n"
161+
"- links_to [[Activity Type Target]]"
162+
),
163+
)
164+
await write_note.fn(
165+
project=test_project.name,
166+
title="Activity Type Target",
167+
directory="mode-tests",
168+
content="# Activity Type Target",
169+
)
170+
171+
relation_json = await recent_activity.fn(
172+
project=test_project.name,
173+
type="relation",
174+
timeframe="7d",
175+
output_format="json",
176+
)
177+
assert isinstance(relation_json, list)
178+
assert relation_json
179+
for item in relation_json:
180+
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
181+
182+
observation_json = await recent_activity.fn(
183+
project=test_project.name,
184+
type="observation",
185+
timeframe="7d",
186+
output_format="json",
187+
)
188+
assert isinstance(observation_json, list)
189+
assert observation_json
190+
for item in observation_json:
191+
assert set(["title", "permalink", "file_path", "created_at"]).issubset(item.keys())
192+
193+
151194
@pytest.mark.asyncio
152195
async def test_list_and_create_project_text_and_json_modes(app, test_project, tmp_path):
153196
list_text = await list_memory_projects.fn(output_format="text")
@@ -218,6 +261,28 @@ async def test_delete_note_text_and_json_modes(app, test_project):
218261
assert json_delete["file_path"]
219262

220263

264+
@pytest.mark.asyncio
265+
async def test_delete_directory_json_mode_returns_structured_error_on_failure(
266+
app, test_project, monkeypatch
267+
):
268+
async def mock_delete_directory(self, directory: str):
269+
raise RuntimeError("simulated directory delete failure")
270+
271+
monkeypatch.setattr(KnowledgeClient, "delete_directory", mock_delete_directory)
272+
273+
json_delete = await delete_note.fn(
274+
identifier="mode-tests",
275+
is_directory=True,
276+
project=test_project.name,
277+
output_format="json",
278+
)
279+
assert isinstance(json_delete, dict)
280+
assert json_delete["deleted"] is False
281+
assert json_delete["is_directory"] is True
282+
assert json_delete["identifier"] == "mode-tests"
283+
assert "simulated directory delete failure" in json_delete["error"]
284+
285+
221286
@pytest.mark.asyncio
222287
async def test_move_note_text_and_json_modes(app, test_project):
223288
await write_note.fn(

0 commit comments

Comments
 (0)