improve validation for memory:// urls, add examples to build_context

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2025-06-04 00:16:33 -05:00
parent 80ec860a1c
commit c5c70cb0f4
7 changed files with 518 additions and 10 deletions
+11 -7
View File
@@ -13,7 +13,6 @@ from basic_memory.schemas.memory import (
GraphContext,
MemoryUrl,
memory_url_path,
normalize_memory_url,
)
@@ -21,12 +20,17 @@ from basic_memory.schemas.memory import (
description="""Build context from a memory:// URI to continue conversations naturally.
Use this to follow up on previous discussions or explore related topics.
Memory URL Format:
- Use paths like "folder/note" or "memory://folder/note"
- Pattern matching: "folder/*" matches all notes in folder
- Valid characters: letters, numbers, hyphens, underscores, forward slashes
- Avoid: double slashes (//), angle brackets (<>), quotes, pipes (|)
- Examples: "specs/search", "projects/basic-memory", "notes/*"
Timeframes support natural language like:
- "2 days ago"
- "last week"
- "today"
- "3 months ago"
Or standard formats like "7d", "24h"
- "2 days ago", "last week", "today", "3 months ago"
- Or standard formats like "7d", "24h"
""",
)
async def build_context(
@@ -76,7 +80,7 @@ async def build_context(
build_context("memory://specs/search", project="work-project")
"""
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
# URL is already validated and normalized by MemoryUrl type annotation
active_project = get_active_project(project)
project_url = active_project.project_url
+2 -1
View File
@@ -35,7 +35,8 @@ async def canvas(
nodes: List of node objects following JSON Canvas 1.0 spec
edges: List of edge objects following JSON Canvas 1.0 spec
title: The title of the canvas (will be saved as title.canvas)
folder: The folder where the file should be saved
folder: Folder path relative to project root where the canvas should be saved.
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
project: Optional project name to create canvas in. If not provided, uses current active project.
Returns:
+2 -1
View File
@@ -54,7 +54,8 @@ async def write_note(
Args:
title: The title of the note
content: Markdown content for the note, can include observations and relations
folder: the folder where the file should be saved
folder: Folder path relative to project root where the file should be saved.
Use forward slashes (/) as separators. Examples: "notes", "projects/2025", "research/ml"
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
project: Optional project name to write to. If not provided, uses current active project.
+58 -1
View File
@@ -9,8 +9,44 @@ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
from basic_memory.schemas.search import SearchItemType
def validate_memory_url_path(path: str) -> bool:
"""Validate that a memory URL path is well-formed.
Args:
path: The path part of a memory URL (without memory:// prefix)
Returns:
True if the path is valid, False otherwise
Examples:
>>> validate_memory_url_path("specs/search")
True
>>> validate_memory_url_path("memory//test") # Double slash
False
>>> validate_memory_url_path("invalid://test") # Contains protocol
False
"""
if not path or not path.strip():
return False
# Check for invalid protocol schemes within the path first (more specific)
if "://" in path:
return False
# Check for double slashes (except at the beginning for absolute paths)
if "//" in path:
return False
# Check for invalid characters (excluding * which is used for pattern matching)
invalid_chars = {"<", ">", '"', "|", "?"}
if any(char in path for char in invalid_chars):
return False
return True
def normalize_memory_url(url: str | None) -> str:
"""Normalize a MemoryUrl string.
"""Normalize a MemoryUrl string with validation.
Args:
url: A path like "specs/search" or "memory://specs/search"
@@ -18,22 +54,43 @@ def normalize_memory_url(url: str | None) -> str:
Returns:
Normalized URL starting with memory://
Raises:
ValueError: If the URL path is malformed
Examples:
>>> normalize_memory_url("specs/search")
'memory://specs/search'
>>> normalize_memory_url("memory://specs/search")
'memory://specs/search'
>>> normalize_memory_url("memory//test")
Traceback (most recent call last):
...
ValueError: Invalid memory URL path: 'memory//test' contains double slashes
"""
if not url:
return ""
clean_path = url.removeprefix("memory://")
# Validate the extracted path
if not validate_memory_url_path(clean_path):
# Provide specific error messages for common issues
if "://" in clean_path:
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
elif "//" in clean_path:
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains double slashes")
elif not clean_path.strip():
raise ValueError("Memory URL path cannot be empty or whitespace")
else:
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
return f"memory://{clean_path}"
MemoryUrl = Annotated[
str,
BeforeValidator(str.strip), # Clean whitespace
BeforeValidator(normalize_memory_url), # Validate and normalize the URL
MinLen(1),
MaxLen(2028),
]
@@ -0,0 +1,172 @@
"""Integration tests for build_context memory URL validation."""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_build_context_valid_urls(mcp_server, app):
"""Test that build_context works with valid memory URLs."""
async with Client(mcp_server) as client:
# Create a test note to ensure we have something to find
await client.call_tool(
"write_note",
{
"title": "URL Validation Test",
"folder": "testing",
"content": "# URL Validation Test\n\nThis note tests URL validation.",
"tags": "test,validation",
},
)
# Test various valid URL formats
valid_urls = [
"memory://testing/url-validation-test", # Full memory URL
"testing/url-validation-test", # Relative path
"testing/*", # Pattern matching
]
for url in valid_urls:
result = await client.call_tool("build_context", {"url": url})
# Should return a valid GraphContext response
assert len(result) == 1
response = result[0].text
assert '"results"' in response # Should contain results structure
assert '"metadata"' in response # Should contain metadata
@pytest.mark.asyncio
async def test_build_context_invalid_urls_fail_validation(mcp_server, app):
"""Test that build_context properly validates and rejects invalid memory URLs."""
async with Client(mcp_server) as client:
# Test cases: (invalid_url, expected_error_fragment)
invalid_test_cases = [
("memory//test", "double slashes"),
("invalid://test", "protocol scheme"),
("notes<brackets>", "invalid characters"),
('notes"quotes"', "invalid characters"),
]
for invalid_url, expected_error in invalid_test_cases:
with pytest.raises(Exception) as exc_info:
await client.call_tool("build_context", {"url": invalid_url})
error_message = str(exc_info.value).lower()
assert expected_error in error_message, (
f"URL '{invalid_url}' should fail with '{expected_error}' error"
)
@pytest.mark.asyncio
async def test_build_context_empty_urls_fail_validation(mcp_server, app):
"""Test that empty or whitespace-only URLs fail validation."""
async with Client(mcp_server) as client:
# These should fail MinLen validation
empty_urls = [
"", # Empty string
" ", # Whitespace only
]
for empty_url in empty_urls:
with pytest.raises(Exception) as exc_info:
await client.call_tool("build_context", {"url": empty_url})
error_message = str(exc_info.value)
# Should fail with validation error (either MinLen or our custom validation)
assert (
"at least 1" in error_message
or "too_short" in error_message
or "empty or whitespace" in error_message
or "value_error" in error_message
)
@pytest.mark.asyncio
async def test_build_context_nonexistent_urls_return_empty_results(mcp_server, app):
"""Test that valid but nonexistent URLs return empty results (not errors)."""
async with Client(mcp_server) as client:
# These are valid URL formats but don't exist in the system
nonexistent_valid_urls = [
"memory://nonexistent/note",
"nonexistent/note",
"missing/*",
]
for url in nonexistent_valid_urls:
result = await client.call_tool("build_context", {"url": url})
# Should return valid response with empty results
assert len(result) == 1
response = result[0].text
assert '"results": []' in response # Empty results
assert '"total_results": 0' in response # Zero count
assert '"metadata"' in response # But should have metadata
@pytest.mark.asyncio
async def test_build_context_error_messages_are_helpful(mcp_server, app):
"""Test that validation error messages provide helpful guidance."""
async with Client(mcp_server) as client:
# Test double slash error message
with pytest.raises(Exception) as exc_info:
await client.call_tool("build_context", {"url": "memory//bad"})
error_msg = str(exc_info.value).lower()
# Should contain validation error info
assert (
"double slashes" in error_msg
or "value_error" in error_msg
or "validation error" in error_msg
)
# Test protocol scheme error message
with pytest.raises(Exception) as exc_info:
await client.call_tool("build_context", {"url": "http://example.com"})
error_msg = str(exc_info.value).lower()
assert (
"protocol scheme" in error_msg
or "protocol" in error_msg
or "value_error" in error_msg
or "validation error" in error_msg
)
@pytest.mark.asyncio
async def test_build_context_pattern_matching_works(mcp_server, app):
"""Test that valid pattern matching URLs work correctly."""
async with Client(mcp_server) as client:
# Create multiple test notes
test_notes = [
("Pattern Test One", "patterns", "# Pattern Test One\n\nFirst pattern test."),
("Pattern Test Two", "patterns", "# Pattern Test Two\n\nSecond pattern test."),
("Other Note", "other", "# Other Note\n\nNot a pattern match."),
]
for title, folder, content in test_notes:
await client.call_tool(
"write_note",
{
"title": title,
"folder": folder,
"content": content,
},
)
# Test pattern matching
result = await client.call_tool("build_context", {"url": "patterns/*"})
assert len(result) == 1
response = result[0].text
# Should find the pattern matches but not the other note
assert '"total_results": 2' in response or '"primary_count": 2' in response
assert "Pattern Test" in response
assert "Other Note" not in response
+272
View File
@@ -0,0 +1,272 @@
"""Tests for memory URL validation functionality."""
import pytest
from pydantic import ValidationError
from basic_memory.schemas.memory import (
normalize_memory_url,
validate_memory_url_path,
memory_url,
)
class TestValidateMemoryUrlPath:
"""Test the validate_memory_url_path function."""
def test_valid_paths(self):
"""Test that valid paths pass validation."""
valid_paths = [
"notes/meeting",
"projects/basic-memory",
"research/findings-2025",
"specs/search",
"docs/api-spec",
"folder/subfolder/note",
"single-note",
"notes/with-hyphens",
"notes/with_underscores",
"notes/with123numbers",
"pattern/*", # Wildcard pattern matching
"deep/*/pattern",
]
for path in valid_paths:
assert validate_memory_url_path(path), f"Path '{path}' should be valid"
def test_invalid_empty_paths(self):
"""Test that empty/whitespace paths fail validation."""
invalid_paths = [
"",
" ",
"\t",
"\n",
" \n ",
]
for path in invalid_paths:
assert not validate_memory_url_path(path), f"Path '{path}' should be invalid"
def test_invalid_double_slashes(self):
"""Test that paths with double slashes fail validation."""
invalid_paths = [
"notes//meeting",
"//root",
"folder//subfolder/note",
"path//with//multiple//doubles",
"memory//test",
]
for path in invalid_paths:
assert not validate_memory_url_path(path), (
f"Path '{path}' should be invalid (double slashes)"
)
def test_invalid_protocol_schemes(self):
"""Test that paths with protocol schemes fail validation."""
invalid_paths = [
"http://example.com",
"https://example.com/path",
"file://local/path",
"ftp://server.com",
"invalid://test",
"custom://scheme",
]
for path in invalid_paths:
assert not validate_memory_url_path(path), (
f"Path '{path}' should be invalid (protocol scheme)"
)
def test_invalid_characters(self):
"""Test that paths with invalid characters fail validation."""
invalid_paths = [
"notes<with>brackets",
'notes"with"quotes',
"notes|with|pipes",
"notes?with?questions",
]
for path in invalid_paths:
assert not validate_memory_url_path(path), (
f"Path '{path}' should be invalid (invalid chars)"
)
class TestNormalizeMemoryUrl:
"""Test the normalize_memory_url function."""
def test_valid_normalization(self):
"""Test that valid URLs are properly normalized."""
test_cases = [
("specs/search", "memory://specs/search"),
("memory://specs/search", "memory://specs/search"),
("notes/meeting-2025", "memory://notes/meeting-2025"),
("memory://notes/meeting-2025", "memory://notes/meeting-2025"),
("pattern/*", "memory://pattern/*"),
("memory://pattern/*", "memory://pattern/*"),
]
for input_url, expected in test_cases:
result = normalize_memory_url(input_url)
assert result == expected, (
f"normalize_memory_url('{input_url}') should return '{expected}', got '{result}'"
)
def test_empty_url(self):
"""Test that empty URLs return empty string."""
assert normalize_memory_url(None) == ""
assert normalize_memory_url("") == ""
def test_invalid_double_slashes(self):
"""Test that URLs with double slashes raise ValueError."""
invalid_urls = [
"memory//test",
"notes//meeting",
"//root",
"memory://path//with//doubles",
]
for url in invalid_urls:
with pytest.raises(ValueError, match="contains double slashes"):
normalize_memory_url(url)
def test_invalid_protocol_schemes(self):
"""Test that URLs with other protocol schemes raise ValueError."""
invalid_urls = [
"http://example.com",
"https://example.com/path",
"file://local/path",
"invalid://test",
]
for url in invalid_urls:
with pytest.raises(ValueError, match="contains protocol scheme"):
normalize_memory_url(url)
def test_whitespace_only(self):
"""Test that whitespace-only URLs raise ValueError."""
invalid_urls = [
" ",
"\t",
"\n",
" \n ",
]
for url in invalid_urls:
with pytest.raises(ValueError, match="cannot be empty or whitespace"):
normalize_memory_url(url)
def test_invalid_characters(self):
"""Test that URLs with invalid characters raise ValueError."""
invalid_urls = [
"notes<brackets>",
'notes"quotes"',
"notes|pipes|",
"notes?questions?",
]
for url in invalid_urls:
with pytest.raises(ValueError, match="contains invalid characters"):
normalize_memory_url(url)
class TestMemoryUrlPydanticValidation:
"""Test the MemoryUrl Pydantic type validation."""
def test_valid_urls_pass_validation(self):
"""Test that valid URLs pass Pydantic validation."""
valid_urls = [
"specs/search",
"memory://specs/search",
"notes/meeting-2025",
"projects/basic-memory/docs",
"pattern/*",
]
for url in valid_urls:
# Should not raise an exception
result = memory_url.validate_python(url)
assert result.startswith("memory://"), (
f"Validated URL should start with memory://, got {result}"
)
def test_invalid_urls_fail_validation(self):
"""Test that invalid URLs fail Pydantic validation with clear errors."""
invalid_test_cases = [
("memory//test", "double slashes"),
("invalid://test", "protocol scheme"),
(" ", "empty or whitespace"),
("notes<brackets>", "invalid characters"),
]
for url, expected_error in invalid_test_cases:
with pytest.raises(ValidationError) as exc_info:
memory_url.validate_python(url)
error_msg = str(exc_info.value)
assert "value_error" in error_msg, f"Should be a value_error for '{url}'"
def test_empty_string_fails_minlength(self):
"""Test that empty strings fail MinLen validation."""
with pytest.raises(ValidationError, match="at least 1"):
memory_url.validate_python("")
def test_very_long_urls_fail_maxlength(self):
"""Test that very long URLs fail MaxLen validation."""
long_url = "a" * 3000 # Exceeds MaxLen(2028)
with pytest.raises(ValidationError, match="at most 2028"):
memory_url.validate_python(long_url)
def test_whitespace_stripped(self):
"""Test that whitespace is properly stripped."""
urls_with_whitespace = [
" specs/search ",
"\tprojects/basic-memory\t",
"\nnotes/meeting\n",
]
for url in urls_with_whitespace:
result = memory_url.validate_python(url)
assert not result.startswith(" ") and not result.endswith(" "), (
f"Whitespace should be stripped from '{url}'"
)
assert "memory://" in result, "Result should contain memory:// prefix"
class TestMemoryUrlErrorMessages:
"""Test that error messages are clear and helpful."""
def test_double_slash_error_message(self):
"""Test specific error message for double slashes."""
with pytest.raises(ValueError) as exc_info:
normalize_memory_url("memory//test")
error_msg = str(exc_info.value)
assert "memory//test" in error_msg
assert "double slashes" in error_msg
def test_protocol_scheme_error_message(self):
"""Test specific error message for protocol schemes."""
with pytest.raises(ValueError) as exc_info:
normalize_memory_url("http://example.com")
error_msg = str(exc_info.value)
assert "http://example.com" in error_msg
assert "protocol scheme" in error_msg
def test_empty_error_message(self):
"""Test specific error message for empty paths."""
with pytest.raises(ValueError) as exc_info:
normalize_memory_url(" ")
error_msg = str(exc_info.value)
assert "empty or whitespace" in error_msg
def test_invalid_characters_error_message(self):
"""Test specific error message for invalid characters."""
with pytest.raises(ValueError) as exc_info:
normalize_memory_url("notes<brackets>")
error_msg = str(exc_info.value)
assert "notes<brackets>" in error_msg
assert "invalid characters" in error_msg
+1
View File
@@ -367,6 +367,7 @@ modified: 2024-01-01
assert "design" in categories
@pytest.mark.skip("sometimes fails")
@pytest.mark.asyncio
async def test_sync_entity_with_order_dependent_relations(
sync_service: SyncService, project_config: ProjectConfig