Skip to content

Commit c5c70cb

Browse files
committed
improve validation for memory:// urls, add examples to build_context
Signed-off-by: phernandez <[email protected]>
1 parent 80ec860 commit c5c70cb

File tree

7 files changed

+518
-10
lines changed

7 files changed

+518
-10
lines changed

src/basic_memory/mcp/tools/build_context.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,24 @@
1313
GraphContext,
1414
MemoryUrl,
1515
memory_url_path,
16-
normalize_memory_url,
1716
)
1817

1918

2019
@mcp.tool(
2120
description="""Build context from a memory:// URI to continue conversations naturally.
2221
2322
Use this to follow up on previous discussions or explore related topics.
23+
24+
Memory URL Format:
25+
- Use paths like "folder/note" or "memory://folder/note"
26+
- Pattern matching: "folder/*" matches all notes in folder
27+
- Valid characters: letters, numbers, hyphens, underscores, forward slashes
28+
- Avoid: double slashes (//), angle brackets (<>), quotes, pipes (|)
29+
- Examples: "specs/search", "projects/basic-memory", "notes/*"
30+
2431
Timeframes support natural language like:
25-
- "2 days ago"
26-
- "last week"
27-
- "today"
28-
- "3 months ago"
29-
Or standard formats like "7d", "24h"
32+
- "2 days ago", "last week", "today", "3 months ago"
33+
- Or standard formats like "7d", "24h"
3034
""",
3135
)
3236
async def build_context(
@@ -76,7 +80,7 @@ async def build_context(
7680
build_context("memory://specs/search", project="work-project")
7781
"""
7882
logger.info(f"Building context from {url}")
79-
url = normalize_memory_url(url)
83+
# URL is already validated and normalized by MemoryUrl type annotation
8084

8185
active_project = get_active_project(project)
8286
project_url = active_project.project_url

src/basic_memory/mcp/tools/canvas.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ async def canvas(
3535
nodes: List of node objects following JSON Canvas 1.0 spec
3636
edges: List of edge objects following JSON Canvas 1.0 spec
3737
title: The title of the canvas (will be saved as title.canvas)
38-
folder: The folder where the file should be saved
38+
folder: Folder path relative to project root where the canvas should be saved.
39+
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
3940
project: Optional project name to create canvas in. If not provided, uses current active project.
4041
4142
Returns:

src/basic_memory/mcp/tools/write_note.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ async def write_note(
5454
Args:
5555
title: The title of the note
5656
content: Markdown content for the note, can include observations and relations
57-
folder: the folder where the file should be saved
57+
folder: Folder path relative to project root where the file should be saved.
58+
Use forward slashes (/) as separators. Examples: "notes", "projects/2025", "research/ml"
5859
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
5960
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
6061
project: Optional project name to write to. If not provided, uses current active project.

src/basic_memory/schemas/memory.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,88 @@
99
from basic_memory.schemas.search import SearchItemType
1010

1111

12+
def validate_memory_url_path(path: str) -> bool:
13+
"""Validate that a memory URL path is well-formed.
14+
15+
Args:
16+
path: The path part of a memory URL (without memory:// prefix)
17+
18+
Returns:
19+
True if the path is valid, False otherwise
20+
21+
Examples:
22+
>>> validate_memory_url_path("specs/search")
23+
True
24+
>>> validate_memory_url_path("memory//test") # Double slash
25+
False
26+
>>> validate_memory_url_path("invalid://test") # Contains protocol
27+
False
28+
"""
29+
if not path or not path.strip():
30+
return False
31+
32+
# Check for invalid protocol schemes within the path first (more specific)
33+
if "://" in path:
34+
return False
35+
36+
# Check for double slashes (except at the beginning for absolute paths)
37+
if "//" in path:
38+
return False
39+
40+
# Check for invalid characters (excluding * which is used for pattern matching)
41+
invalid_chars = {"<", ">", '"', "|", "?"}
42+
if any(char in path for char in invalid_chars):
43+
return False
44+
45+
return True
46+
47+
1248
def normalize_memory_url(url: str | None) -> str:
13-
"""Normalize a MemoryUrl string.
49+
"""Normalize a MemoryUrl string with validation.
1450
1551
Args:
1652
url: A path like "specs/search" or "memory://specs/search"
1753
1854
Returns:
1955
Normalized URL starting with memory://
2056
57+
Raises:
58+
ValueError: If the URL path is malformed
59+
2160
Examples:
2261
>>> normalize_memory_url("specs/search")
2362
'memory://specs/search'
2463
>>> normalize_memory_url("memory://specs/search")
2564
'memory://specs/search'
65+
>>> normalize_memory_url("memory//test")
66+
Traceback (most recent call last):
67+
...
68+
ValueError: Invalid memory URL path: 'memory//test' contains double slashes
2669
"""
2770
if not url:
2871
return ""
2972

3073
clean_path = url.removeprefix("memory://")
74+
75+
# Validate the extracted path
76+
if not validate_memory_url_path(clean_path):
77+
# Provide specific error messages for common issues
78+
if "://" in clean_path:
79+
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
80+
elif "//" in clean_path:
81+
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
82+
elif not clean_path.strip():
83+
raise ValueError("Memory URL path cannot be empty or whitespace")
84+
else:
85+
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
86+
3187
return f"memory://{clean_path}"
3288

3389

3490
MemoryUrl = Annotated[
3591
str,
3692
BeforeValidator(str.strip), # Clean whitespace
93+
BeforeValidator(normalize_memory_url), # Validate and normalize the URL
3794
MinLen(1),
3895
MaxLen(2028),
3996
]
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Integration tests for build_context memory URL validation."""
2+
3+
import pytest
4+
from fastmcp import Client
5+
6+
7+
@pytest.mark.asyncio
8+
async def test_build_context_valid_urls(mcp_server, app):
9+
"""Test that build_context works with valid memory URLs."""
10+
11+
async with Client(mcp_server) as client:
12+
# Create a test note to ensure we have something to find
13+
await client.call_tool(
14+
"write_note",
15+
{
16+
"title": "URL Validation Test",
17+
"folder": "testing",
18+
"content": "# URL Validation Test\n\nThis note tests URL validation.",
19+
"tags": "test,validation",
20+
},
21+
)
22+
23+
# Test various valid URL formats
24+
valid_urls = [
25+
"memory://testing/url-validation-test", # Full memory URL
26+
"testing/url-validation-test", # Relative path
27+
"testing/*", # Pattern matching
28+
]
29+
30+
for url in valid_urls:
31+
result = await client.call_tool("build_context", {"url": url})
32+
33+
# Should return a valid GraphContext response
34+
assert len(result) == 1
35+
response = result[0].text
36+
assert '"results"' in response # Should contain results structure
37+
assert '"metadata"' in response # Should contain metadata
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_build_context_invalid_urls_fail_validation(mcp_server, app):
42+
"""Test that build_context properly validates and rejects invalid memory URLs."""
43+
44+
async with Client(mcp_server) as client:
45+
# Test cases: (invalid_url, expected_error_fragment)
46+
invalid_test_cases = [
47+
("memory//test", "double slashes"),
48+
("invalid://test", "protocol scheme"),
49+
("notes<brackets>", "invalid characters"),
50+
('notes"quotes"', "invalid characters"),
51+
]
52+
53+
for invalid_url, expected_error in invalid_test_cases:
54+
with pytest.raises(Exception) as exc_info:
55+
await client.call_tool("build_context", {"url": invalid_url})
56+
57+
error_message = str(exc_info.value).lower()
58+
assert expected_error in error_message, (
59+
f"URL '{invalid_url}' should fail with '{expected_error}' error"
60+
)
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_build_context_empty_urls_fail_validation(mcp_server, app):
65+
"""Test that empty or whitespace-only URLs fail validation."""
66+
67+
async with Client(mcp_server) as client:
68+
# These should fail MinLen validation
69+
empty_urls = [
70+
"", # Empty string
71+
" ", # Whitespace only
72+
]
73+
74+
for empty_url in empty_urls:
75+
with pytest.raises(Exception) as exc_info:
76+
await client.call_tool("build_context", {"url": empty_url})
77+
78+
error_message = str(exc_info.value)
79+
# Should fail with validation error (either MinLen or our custom validation)
80+
assert (
81+
"at least 1" in error_message
82+
or "too_short" in error_message
83+
or "empty or whitespace" in error_message
84+
or "value_error" in error_message
85+
)
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_build_context_nonexistent_urls_return_empty_results(mcp_server, app):
90+
"""Test that valid but nonexistent URLs return empty results (not errors)."""
91+
92+
async with Client(mcp_server) as client:
93+
# These are valid URL formats but don't exist in the system
94+
nonexistent_valid_urls = [
95+
"memory://nonexistent/note",
96+
"nonexistent/note",
97+
"missing/*",
98+
]
99+
100+
for url in nonexistent_valid_urls:
101+
result = await client.call_tool("build_context", {"url": url})
102+
103+
# Should return valid response with empty results
104+
assert len(result) == 1
105+
response = result[0].text
106+
assert '"results": []' in response # Empty results
107+
assert '"total_results": 0' in response # Zero count
108+
assert '"metadata"' in response # But should have metadata
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_build_context_error_messages_are_helpful(mcp_server, app):
113+
"""Test that validation error messages provide helpful guidance."""
114+
115+
async with Client(mcp_server) as client:
116+
# Test double slash error message
117+
with pytest.raises(Exception) as exc_info:
118+
await client.call_tool("build_context", {"url": "memory//bad"})
119+
120+
error_msg = str(exc_info.value).lower()
121+
# Should contain validation error info
122+
assert (
123+
"double slashes" in error_msg
124+
or "value_error" in error_msg
125+
or "validation error" in error_msg
126+
)
127+
128+
# Test protocol scheme error message
129+
with pytest.raises(Exception) as exc_info:
130+
await client.call_tool("build_context", {"url": "http://example.com"})
131+
132+
error_msg = str(exc_info.value).lower()
133+
assert (
134+
"protocol scheme" in error_msg
135+
or "protocol" in error_msg
136+
or "value_error" in error_msg
137+
or "validation error" in error_msg
138+
)
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_build_context_pattern_matching_works(mcp_server, app):
143+
"""Test that valid pattern matching URLs work correctly."""
144+
145+
async with Client(mcp_server) as client:
146+
# Create multiple test notes
147+
test_notes = [
148+
("Pattern Test One", "patterns", "# Pattern Test One\n\nFirst pattern test."),
149+
("Pattern Test Two", "patterns", "# Pattern Test Two\n\nSecond pattern test."),
150+
("Other Note", "other", "# Other Note\n\nNot a pattern match."),
151+
]
152+
153+
for title, folder, content in test_notes:
154+
await client.call_tool(
155+
"write_note",
156+
{
157+
"title": title,
158+
"folder": folder,
159+
"content": content,
160+
},
161+
)
162+
163+
# Test pattern matching
164+
result = await client.call_tool("build_context", {"url": "patterns/*"})
165+
166+
assert len(result) == 1
167+
response = result[0].text
168+
169+
# Should find the pattern matches but not the other note
170+
assert '"total_results": 2' in response or '"primary_count": 2' in response
171+
assert "Pattern Test" in response
172+
assert "Other Note" not in response

0 commit comments

Comments
 (0)