Skip to content

Commit 9a0e0bd

Browse files
committed
add view_note tool
Signed-off-by: phernandez <[email protected]>
1 parent 117fa44 commit 9a0e0bd

File tree

3 files changed

+370
-0
lines changed

3 files changed

+370
-0
lines changed

src/basic_memory/mcp/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from basic_memory.mcp.tools.build_context import build_context
1212
from basic_memory.mcp.tools.recent_activity import recent_activity
1313
from basic_memory.mcp.tools.read_note import read_note
14+
from basic_memory.mcp.tools.view_note import view_note
1415
from basic_memory.mcp.tools.write_note import write_note
1516
from basic_memory.mcp.tools.search import search_notes
1617
from basic_memory.mcp.tools.canvas import canvas
@@ -45,5 +46,6 @@
4546
"set_default_project",
4647
"switch_project",
4748
"sync_status",
49+
"view_note",
4850
"write_note",
4951
]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""View note tool for Basic Memory MCP server."""
2+
3+
from textwrap import dedent
4+
from typing import Optional
5+
6+
from loguru import logger
7+
8+
from basic_memory.mcp.server import mcp
9+
from basic_memory.mcp.tools.read_note import read_note
10+
11+
12+
@mcp.tool(
13+
description="View a note as a formatted artifact for better readability.",
14+
)
15+
async def view_note(
16+
identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
17+
) -> str:
18+
"""View a markdown note as a formatted artifact.
19+
20+
This tool reads a note using the same logic as read_note but displays the content
21+
as a markdown artifact for better viewing experience in Claude Desktop.
22+
23+
After calling this tool, create an artifact using the returned content to display
24+
the note in a readable format. The tool returns the note content that should be
25+
used to create a markdown artifact.
26+
27+
Args:
28+
identifier: The title or permalink of the note to view
29+
page: Page number for paginated results (default: 1)
30+
page_size: Number of items per page (default: 10)
31+
project: Optional project name to read from. If not provided, uses current active project.
32+
33+
Returns:
34+
The note content as a markdown artifact with a confirmation message.
35+
"""
36+
37+
logger.info(f"Viewing note: {identifier}")
38+
39+
# Call the existing read_note logic
40+
content = await read_note(identifier, page, page_size, project)
41+
42+
# Check if this is an error message (note not found)
43+
if "# Note Not Found:" in content:
44+
return content # Return error message directly instead of creating artifact
45+
46+
# Extract title from content if possible
47+
title = identifier
48+
lines = content.split("\n")
49+
for line in lines:
50+
if line.startswith("title:"):
51+
# Extract title from frontmatter
52+
title = line.split("title:", 1)[1].strip().strip('"').strip("'")
53+
break
54+
elif line.startswith("# "):
55+
# Extract title from first heading
56+
title = line[2:].strip()
57+
break
58+
59+
# Return the artifact XML as part of the response
60+
artifact = f'<artifact identifier="note-{hash(identifier) & 0x7FFFFFFF}" type="text/markdown" title="{title}">\n{content}\n</artifact>'
61+
62+
return dedent(f"""
63+
<instructions>
64+
Create an artifact using the returned artifact content to display the note in a readable format.
65+
</instructions>
66+
{artifact}\n\n✅ Note displayed as artifact: **{title}**""")

tests/mcp/test_tool_view_note.py

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
"""Tests for view_note tool that exercise the full stack with SQLite."""
2+
3+
from textwrap import dedent
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
import pytest_asyncio
8+
9+
from basic_memory.mcp.tools import write_note, view_note
10+
from basic_memory.schemas.search import SearchResponse, SearchItemType
11+
12+
13+
@pytest_asyncio.fixture
14+
async def mock_call_get():
15+
"""Mock for call_get to simulate different responses."""
16+
with patch("basic_memory.mcp.tools.read_note.call_get") as mock:
17+
# Default to 404 - not found
18+
mock_response = MagicMock()
19+
mock_response.status_code = 404
20+
mock.return_value = mock_response
21+
yield mock
22+
23+
24+
@pytest_asyncio.fixture
25+
async def mock_search():
26+
"""Mock for search tool."""
27+
with patch("basic_memory.mcp.tools.read_note.search_notes") as mock:
28+
# Default to empty results
29+
mock.return_value = SearchResponse(results=[], current_page=1, page_size=1)
30+
yield mock
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_view_note_basic_functionality(app):
35+
"""Test viewing a note creates an artifact."""
36+
# First create a note
37+
await write_note(
38+
title="Test View Note",
39+
folder="test",
40+
content="# Test View Note\n\nThis is test content for viewing.",
41+
)
42+
43+
# View the note
44+
result = await view_note("Test View Note")
45+
46+
# Should contain artifact XML
47+
assert '<artifact identifier="note-' in result
48+
assert 'type="text/markdown"' in result
49+
assert 'title="Test View Note"' in result
50+
assert "</artifact>" in result
51+
52+
# Should contain the note content within the artifact
53+
assert "# Test View Note" in result
54+
assert "This is test content for viewing." in result
55+
56+
# Should have confirmation message
57+
assert "✅ Note displayed as artifact" in result
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_view_note_with_frontmatter_title(app):
62+
"""Test viewing a note extracts title from frontmatter."""
63+
# Create note with frontmatter
64+
content = dedent("""
65+
---
66+
title: "Frontmatter Title"
67+
tags: [test]
68+
---
69+
70+
# Frontmatter Title
71+
72+
Content with frontmatter title.
73+
""").strip()
74+
75+
await write_note(title="Frontmatter Title", folder="test", content=content)
76+
77+
# View the note
78+
result = await view_note("Frontmatter Title")
79+
80+
# Should extract title from frontmatter
81+
assert 'title="Frontmatter Title"' in result
82+
assert "✅ Note displayed as artifact: **Frontmatter Title**" in result
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_view_note_with_heading_title(app):
87+
"""Test viewing a note extracts title from first heading when no frontmatter."""
88+
# Create note with heading but no frontmatter title
89+
content = "# Heading Title\n\nContent with heading title."
90+
91+
await write_note(title="Heading Title", folder="test", content=content)
92+
93+
# View the note
94+
result = await view_note("Heading Title")
95+
96+
# Should extract title from heading
97+
assert 'title="Heading Title"' in result
98+
assert "✅ Note displayed as artifact: **Heading Title**" in result
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_view_note_unicode_content(app):
103+
"""Test viewing a note with Unicode content."""
104+
content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦"
105+
106+
await write_note(title="Unicode Test 🚀", folder="test", content=content)
107+
108+
# View the note
109+
result = await view_note("Unicode Test 🚀")
110+
111+
# Should handle Unicode properly
112+
assert "🚀" in result
113+
assert "🎉" in result
114+
assert "♠♣♥♦" in result
115+
assert '<artifact identifier="note-' in result
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_view_note_by_permalink(app):
120+
"""Test viewing a note by its permalink."""
121+
await write_note(title="Permalink Test", folder="test", content="Content for permalink test.")
122+
123+
# View by permalink
124+
result = await view_note("test/permalink-test")
125+
126+
# Should work with permalink
127+
assert '<artifact identifier="note-' in result
128+
assert "Content for permalink test." in result
129+
assert "✅ Note displayed as artifact" in result
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_view_note_with_memory_url(app):
134+
"""Test viewing a note using a memory:// URL."""
135+
await write_note(
136+
title="Memory URL Test",
137+
folder="test",
138+
content="Testing memory:// URL handling in view_note",
139+
)
140+
141+
# View with memory:// URL
142+
result = await view_note("memory://test/memory-url-test")
143+
144+
# Should work with memory:// URL
145+
assert '<artifact identifier="note-' in result
146+
assert "Testing memory:// URL handling in view_note" in result
147+
assert "✅ Note displayed as artifact" in result
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_view_note_not_found(app):
152+
"""Test viewing a non-existent note returns error without artifact."""
153+
# Try to view non-existent note
154+
result = await view_note("NonExistent Note")
155+
156+
# Should return error message without artifact
157+
assert "# Note Not Found:" in result
158+
assert "NonExistent Note" in result
159+
assert "<artifact" not in result # No artifact for errors
160+
assert "Check Identifier Type" in result
161+
assert "Search Instead" in result
162+
163+
164+
@pytest.mark.asyncio
165+
async def test_view_note_pagination(app):
166+
"""Test viewing a note with pagination parameters."""
167+
await write_note(title="Pagination Test", folder="test", content="Content for pagination test.")
168+
169+
# View with pagination
170+
result = await view_note("Pagination Test", page=1, page_size=5)
171+
172+
# Should work with pagination
173+
assert '<artifact identifier="note-' in result
174+
assert "Content for pagination test." in result
175+
assert "✅ Note displayed as artifact" in result
176+
177+
178+
@pytest.mark.asyncio
179+
async def test_view_note_project_parameter(app):
180+
"""Test viewing a note with project parameter."""
181+
await write_note(title="Project Test", folder="test", content="Content for project test.")
182+
183+
# View with explicit project (None uses current)
184+
result = await view_note("Project Test", project=None)
185+
186+
# Should work with project parameter
187+
assert '<artifact identifier="note-' in result
188+
assert "Content for project test." in result
189+
assert "✅ Note displayed as artifact" in result
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_view_note_artifact_identifier_unique(app):
194+
"""Test that different notes get different artifact identifiers."""
195+
# Create two notes
196+
await write_note(title="Note One", folder="test", content="Content one")
197+
await write_note(title="Note Two", folder="test", content="Content two")
198+
199+
# View both notes
200+
result1 = await view_note("Note One")
201+
result2 = await view_note("Note Two")
202+
203+
# Should have different artifact identifiers
204+
import re
205+
206+
id1_match = re.search(r'identifier="(note-\d+)"', result1)
207+
id2_match = re.search(r'identifier="(note-\d+)"', result2)
208+
209+
assert id1_match is not None
210+
assert id2_match is not None
211+
assert id1_match.group(1) != id2_match.group(1)
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_view_note_fallback_identifier_as_title(app):
216+
"""Test that view_note uses identifier as title when no title is extractable."""
217+
# Create a note with no clear title structure
218+
await write_note(
219+
title="Simple Note",
220+
folder="test",
221+
content="Just plain content with no headings or frontmatter title",
222+
)
223+
224+
# View the note
225+
result = await view_note("Simple Note")
226+
227+
# Should use identifier as fallback title
228+
assert 'title="Simple Note"' in result
229+
assert "✅ Note displayed as artifact: **Simple Note**" in result
230+
231+
232+
@pytest.mark.asyncio
233+
async def test_view_note_direct_success(mock_call_get):
234+
"""Test view_note with successful direct permalink lookup."""
235+
# Setup mock for successful response with frontmatter
236+
note_content = dedent("""
237+
---
238+
title: "Test Note"
239+
---
240+
# Test Note
241+
242+
This is a test note.
243+
""").strip()
244+
245+
mock_response = MagicMock()
246+
mock_response.status_code = 200
247+
mock_response.text = note_content
248+
mock_call_get.return_value = mock_response
249+
250+
# Call the function
251+
result = await view_note("test/test-note")
252+
253+
# Verify direct lookup was used
254+
mock_call_get.assert_called_once()
255+
assert "test/test-note" in mock_call_get.call_args[0][1]
256+
257+
# Verify result contains artifact
258+
assert '<artifact identifier="note-' in result
259+
assert 'title="Test Note"' in result
260+
assert "This is a test note." in result
261+
assert "✅ Note displayed as artifact: **Test Note**" in result
262+
263+
264+
@pytest.mark.asyncio
265+
async def test_view_note_title_search_fallback(mock_call_get, mock_search):
266+
"""Test view_note falls back to title search when direct lookup fails."""
267+
# Setup mock for failed direct lookup
268+
mock_call_get.side_effect = [
269+
# First call fails (direct lookup)
270+
MagicMock(status_code=404),
271+
# Second call succeeds (after title search)
272+
MagicMock(status_code=200, text="# Test Note\n\nThis is a test note."),
273+
]
274+
275+
# Setup mock for successful title search
276+
mock_search.return_value = SearchResponse(
277+
results=[
278+
{
279+
"id": 1,
280+
"entity": "test/test-note",
281+
"title": "Test Note",
282+
"type": SearchItemType.ENTITY,
283+
"permalink": "test/test-note",
284+
"file_path": "test/test-note.md",
285+
"score": 1.0,
286+
}
287+
],
288+
current_page=1,
289+
page_size=1,
290+
)
291+
292+
# Call the function
293+
result = await view_note("Test Note")
294+
295+
# Verify title search was used
296+
mock_search.assert_called_once()
297+
298+
# Verify result contains artifact with extracted title
299+
assert '<artifact identifier="note-' in result
300+
assert 'title="Test Note"' in result
301+
assert "This is a test note." in result
302+
assert "✅ Note displayed as artifact: **Test Note**" in result

0 commit comments

Comments
 (0)