1212from basic_memory .cli .commands .command_utils import run_with_cleanup
1313from basic_memory .cli .commands .routing import force_routing , validate_routing_flags
1414from basic_memory .config import ConfigManager
15+ from basic_memory .mcp .async_client import get_client
16+ from basic_memory .mcp .clients import KnowledgeClient , ResourceClient
17+ from basic_memory .mcp .project_context import get_active_project
18+ from basic_memory .mcp .tools .utils import call_get
19+ from basic_memory .schemas .base import Entity , TimeFrame
20+ from basic_memory .schemas .memory import GraphContext , MemoryUrl , memory_url_path
21+ from basic_memory .schemas .search import SearchItemType
1522
1623# Import prompts
1724from basic_memory .mcp .prompts .continue_conversation import (
2532from basic_memory .mcp .tools import recent_activity as mcp_recent_activity
2633from basic_memory .mcp .tools import search_notes as mcp_search
2734from basic_memory .mcp .tools import write_note as mcp_write_note
28- from basic_memory .schemas .base import TimeFrame
29- from basic_memory .schemas .memory import MemoryUrl
30- from basic_memory .schemas .search import SearchItemType
3135
3236tool_app = typer .Typer ()
3337app .add_typer (tool_app , name = "tool" , help = "Access to MCP tools via CLI" )
3438
3539
40+ # --- JSON output helpers ---
41+ # These async functions bypass the MCP tool (which returns formatted strings)
42+ # and use API clients directly to return structured data for --format json.
43+
44+
45+ async def _write_note_json (
46+ title : str , content : str , folder : str , project_name : Optional [str ], tags : Optional [List [str ]]
47+ ) -> dict :
48+ """Write a note and return structured JSON metadata."""
49+ # Use the MCP tool to create/update the entity (handles create-or-update logic)
50+ await mcp_write_note .fn (title , content , folder , project_name , tags )
51+
52+ # Resolve the entity to get metadata back
53+ async with get_client () as client :
54+ active_project = await get_active_project (client , project_name )
55+ knowledge_client = KnowledgeClient (client , active_project .external_id )
56+
57+ entity = Entity (title = title , directory = folder )
58+ if not entity .permalink :
59+ raise ValueError (f"Could not generate permalink for title={ title } , folder={ folder } " )
60+ entity_id = await knowledge_client .resolve_entity (entity .permalink )
61+ entity = await knowledge_client .get_entity (entity_id )
62+
63+ return {
64+ "title" : entity .title ,
65+ "permalink" : entity .permalink ,
66+ "content" : content ,
67+ "file_path" : entity .file_path ,
68+ }
69+
70+
71+ async def _read_note_json (
72+ identifier : str , project_name : Optional [str ], page : int , page_size : int
73+ ) -> dict :
74+ """Read a note and return structured JSON with content and metadata."""
75+ async with get_client () as client :
76+ active_project = await get_active_project (client , project_name )
77+ knowledge_client = KnowledgeClient (client , active_project .external_id )
78+ resource_client = ResourceClient (client , active_project .external_id )
79+
80+ # Try direct resolution first (works for permalinks and memory URLs)
81+ entity_path = memory_url_path (identifier )
82+ entity_id = None
83+ try :
84+ entity_id = await knowledge_client .resolve_entity (entity_path )
85+ except Exception :
86+ logger .info (f"Direct lookup failed for '{ entity_path } ', trying title search" )
87+
88+ # Fallback: title search (handles plain titles like "My Note")
89+ if entity_id is None :
90+ from basic_memory .mcp .tools .search import search_notes as mcp_search_tool
91+
92+ title_results = await mcp_search_tool .fn (
93+ query = identifier , search_type = "title" , project = project_name
94+ )
95+ if title_results and hasattr (title_results , "results" ) and title_results .results :
96+ result = title_results .results [0 ]
97+ if result .permalink :
98+ entity_id = await knowledge_client .resolve_entity (result .permalink )
99+
100+ if entity_id is None :
101+ raise ValueError (f"Could not find note matching: { identifier } " )
102+
103+ entity = await knowledge_client .get_entity (entity_id )
104+ response = await resource_client .read (entity_id , page = page , page_size = page_size )
105+
106+ return {
107+ "title" : entity .title ,
108+ "permalink" : entity .permalink ,
109+ "content" : response .text ,
110+ "file_path" : entity .file_path ,
111+ }
112+
113+
114+ async def _recent_activity_json (
115+ type : Optional [List [SearchItemType ]],
116+ depth : Optional [int ],
117+ timeframe : Optional [TimeFrame ],
118+ project_name : Optional [str ] = None ,
119+ page : int = 1 ,
120+ page_size : int = 50 ,
121+ ) -> list :
122+ """Get recent activity and return structured JSON list."""
123+ async with get_client () as client :
124+ # Build query params matching the MCP tool's logic
125+ params : dict = {"page" : page , "page_size" : page_size , "max_related" : 10 }
126+ if depth :
127+ params ["depth" ] = depth
128+ if timeframe :
129+ params ["timeframe" ] = timeframe
130+ if type :
131+ params ["type" ] = [t .value for t in type ]
132+
133+ active_project = await get_active_project (client , project_name )
134+ response = await call_get (
135+ client ,
136+ f"/v2/projects/{ active_project .external_id } /memory/recent" ,
137+ params = params ,
138+ )
139+ activity_data = GraphContext .model_validate (response .json ())
140+
141+ # Extract entity results
142+ results = []
143+ for result in activity_data .results :
144+ pr = result .primary_result
145+ if pr .type == "entity" :
146+ results .append (
147+ {
148+ "title" : pr .title ,
149+ "permalink" : pr .permalink ,
150+ "file_path" : pr .file_path ,
151+ "created_at" : str (pr .created_at ) if pr .created_at else None ,
152+ }
153+ )
154+ return results
155+
156+
36157@tool_app .command ()
37158def write_note (
38159 title : Annotated [str , typer .Option (help = "The title of the note" )],
@@ -52,6 +173,7 @@ def write_note(
52173 tags : Annotated [
53174 Optional [List [str ]], typer .Option (help = "A list of tags to apply to the note" )
54175 ] = None ,
176+ format : str = typer .Option ("text" , "--format" , help = "Output format: text or json" ),
55177 local : bool = typer .Option (
56178 False , "--local" , help = "Force local API routing (ignore cloud mode)"
57179 ),
@@ -123,9 +245,20 @@ def write_note(
123245 # use the project name, or the default from the config
124246 project_name = project_name or config_manager .default_project
125247
248+ # content is validated non-None above (stdin or --content)
249+ assert content is not None
250+
126251 with force_routing (local = local , cloud = cloud ):
127- note = run_with_cleanup (mcp_write_note .fn (title , content , folder , project_name , tags ))
128- rprint (note )
252+ if format == "json" :
253+ result = run_with_cleanup (
254+ _write_note_json (title , content , folder , project_name , tags )
255+ )
256+ print (json .dumps (result , indent = 2 , ensure_ascii = True , default = str ))
257+ else :
258+ note = run_with_cleanup (
259+ mcp_write_note .fn (title , content , folder , project_name , tags )
260+ )
261+ rprint (note )
129262 except ValueError as e :
130263 typer .echo (f"Error: { e } " , err = True )
131264 raise typer .Exit (1 )
@@ -147,6 +280,7 @@ def read_note(
147280 ] = None ,
148281 page : int = 1 ,
149282 page_size : int = 10 ,
283+ format : str = typer .Option ("text" , "--format" , help = "Output format: text or json" ),
150284 local : bool = typer .Option (
151285 False , "--local" , help = "Force local API routing (ignore cloud mode)"
152286 ),
@@ -173,8 +307,14 @@ def read_note(
173307 project_name = project_name or config_manager .default_project
174308
175309 with force_routing (local = local , cloud = cloud ):
176- note = run_with_cleanup (mcp_read_note .fn (identifier , project_name , page , page_size ))
177- rprint (note )
310+ if format == "json" :
311+ result = run_with_cleanup (
312+ _read_note_json (identifier , project_name , page , page_size )
313+ )
314+ print (json .dumps (result , indent = 2 , ensure_ascii = True , default = str ))
315+ else :
316+ note = run_with_cleanup (mcp_read_note .fn (identifier , project_name , page , page_size ))
317+ rprint (note )
178318 except ValueError as e :
179319 typer .echo (f"Error: { e } " , err = True )
180320 raise typer .Exit (1 )
@@ -197,6 +337,7 @@ def build_context(
197337 page : int = 1 ,
198338 page_size : int = 10 ,
199339 max_related : int = 10 ,
340+ format : str = typer .Option ("json" , "--format" , help = "Output format: text or json" ),
200341 local : bool = typer .Option (
201342 False , "--local" , help = "Force local API routing (ignore cloud mode)"
202343 ),
@@ -234,9 +375,6 @@ def build_context(
234375 max_related = max_related ,
235376 )
236377 )
237- # Use json module for more controlled serialization
238- import json
239-
240378 context_dict = context .model_dump (exclude_none = True )
241379 print (json .dumps (context_dict , indent = 2 , ensure_ascii = True , default = str ))
242380 except ValueError as e :
@@ -252,8 +390,17 @@ def build_context(
252390@tool_app .command ()
253391def recent_activity (
254392 type : Annotated [Optional [List [SearchItemType ]], typer .Option ()] = None ,
393+ project : Annotated [
394+ Optional [str ],
395+ typer .Option (help = "The project to use. If not provided, the default project will be used." ),
396+ ] = None ,
255397 depth : Optional [int ] = 1 ,
256398 timeframe : Optional [TimeFrame ] = "7d" ,
399+ page : int = typer .Option (1 , "--page" , help = "Page number for pagination (JSON format)" ),
400+ page_size : int = typer .Option (
401+ 50 , "--page-size" , help = "Number of results per page (JSON format)"
402+ ),
403+ format : str = typer .Option ("text" , "--format" , help = "Output format: text or json" ),
257404 local : bool = typer .Option (
258405 False , "--local" , help = "Force local API routing (ignore cloud mode)"
259406 ),
@@ -267,16 +414,33 @@ def recent_activity(
267414 try :
268415 validate_routing_flags (local , cloud )
269416
417+ # Resolve project from config for JSON mode
418+ config_manager = ConfigManager ()
419+ project_name = None
420+ if project is not None :
421+ project_name , _ = config_manager .get_project (project )
422+ if not project_name :
423+ typer .echo (f"No project found named: { project } " , err = True )
424+ raise typer .Exit (1 )
425+ project_name = project_name or config_manager .default_project
426+
270427 with force_routing (local = local , cloud = cloud ):
271- result = run_with_cleanup (
272- mcp_recent_activity .fn (
273- type = type , # pyright: ignore [reportArgumentType]
274- depth = depth ,
275- timeframe = timeframe ,
428+ if format == "json" :
429+ result = run_with_cleanup (
430+ _recent_activity_json (type , depth , timeframe , project_name , page , page_size )
276431 )
277- )
278- # The tool now returns a formatted string directly
279- print (result )
432+ print (json .dumps (result , indent = 2 , ensure_ascii = True , default = str ))
433+ else :
434+ result = run_with_cleanup (
435+ mcp_recent_activity .fn (
436+ type = type , # pyright: ignore [reportArgumentType]
437+ depth = depth ,
438+ timeframe = timeframe ,
439+ project = project_name ,
440+ )
441+ )
442+ # The tool returns a formatted string directly
443+ print (result )
280444 except ValueError as e :
281445 typer .echo (f"Error: { e } " , err = True )
282446 raise typer .Exit (1 )
0 commit comments