Skip to content

Commit a7bf42e

Browse files
fix: Add proper datetime JSON schema format annotations for MCP validation (#312)
Signed-off-by: Claude Code <[email protected]> Signed-off-by: phernandez <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <[email protected]>
1 parent 9035913 commit a7bf42e

File tree

5 files changed

+55
-29
lines changed

5 files changed

+55
-29
lines changed

src/basic_memory/mcp/prompts/utils.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,17 @@ def format_prompt_context(context: PromptContext) -> str:
103103

104104
added_permalinks.add(primary_permalink)
105105

106-
memory_url = normalize_memory_url(primary_permalink)
106+
# Use permalink if available, otherwise use file_path
107+
if primary_permalink:
108+
memory_url = normalize_memory_url(primary_permalink)
109+
read_command = f'read_note("{primary_permalink}")'
110+
else:
111+
memory_url = f"file://{primary.file_path}"
112+
read_command = f'read_file("{primary.file_path}")'
113+
107114
section = dedent(f"""
108115
--- {memory_url}
109-
116+
110117
## {primary.title}
111118
- **Type**: {primary.type}
112119
""")
@@ -121,8 +128,8 @@ def format_prompt_context(context: PromptContext) -> str:
121128
section += f"\n**Excerpt**:\n{content}\n"
122129

123130
section += dedent(f"""
124-
125-
You can read this document with: `read_note("{primary_permalink}")`
131+
132+
You can read this document with: `{read_command}`
126133
""")
127134
sections.append(section)
128135

src/basic_memory/schemas/memory.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def validate_memory_url_path(path: str) -> bool:
2626
>>> validate_memory_url_path("invalid://test") # Contains protocol
2727
False
2828
"""
29+
# Empty paths are not valid
2930
if not path or not path.strip():
3031
return False
3132

@@ -68,7 +69,13 @@ def normalize_memory_url(url: str | None) -> str:
6869
ValueError: Invalid memory URL path: 'memory//test' contains double slashes
6970
"""
7071
if not url:
71-
return ""
72+
raise ValueError("Memory URL cannot be empty")
73+
74+
# Strip whitespace for consistency
75+
url = url.strip()
76+
77+
if not url:
78+
raise ValueError("Memory URL cannot be empty or whitespace")
7279

7380
clean_path = url.removeprefix("memory://")
7481

@@ -79,8 +86,6 @@ def normalize_memory_url(url: str | None) -> str:
7986
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains protocol scheme")
8087
elif "//" in clean_path:
8188
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")
8489
else:
8590
raise ValueError(f"Invalid memory URL path: '{clean_path}' contains invalid characters")
8691

@@ -123,7 +128,9 @@ class EntitySummary(BaseModel):
123128
title: str
124129
content: Optional[str] = None
125130
file_path: str
126-
created_at: datetime
131+
created_at: Annotated[
132+
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
133+
]
127134

128135
@field_serializer("created_at")
129136
def serialize_created_at(self, dt: datetime) -> str:
@@ -140,7 +147,9 @@ class RelationSummary(BaseModel):
140147
relation_type: str
141148
from_entity: Optional[str] = None
142149
to_entity: Optional[str] = None
143-
created_at: datetime
150+
created_at: Annotated[
151+
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
152+
]
144153

145154
@field_serializer("created_at")
146155
def serialize_created_at(self, dt: datetime) -> str:
@@ -156,7 +165,9 @@ class ObservationSummary(BaseModel):
156165
permalink: str
157166
category: str
158167
content: str
159-
created_at: datetime
168+
created_at: Annotated[
169+
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
170+
]
160171

161172
@field_serializer("created_at")
162173
def serialize_created_at(self, dt: datetime) -> str:
@@ -170,7 +181,9 @@ class MemoryMetadata(BaseModel):
170181
types: Optional[List[SearchItemType]] = None
171182
depth: int
172183
timeframe: Optional[str] = None
173-
generated_at: datetime
184+
generated_at: Annotated[
185+
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
186+
]
174187
primary_count: Optional[int] = None # Changed field name
175188
related_count: Optional[int] = None # Changed field name
176189
total_results: Optional[int] = None # For backward compatibility
@@ -235,9 +248,9 @@ class ProjectActivity(BaseModel):
235248
project_path: str
236249
activity: GraphContext = Field(description="The actual activity data for this project")
237250
item_count: int = Field(description="Total items in this project's activity")
238-
last_activity: Optional[datetime] = Field(
239-
default=None, description="Most recent activity timestamp"
240-
)
251+
last_activity: Optional[
252+
Annotated[datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})]
253+
] = Field(default=None, description="Most recent activity timestamp")
241254
active_folders: List[str] = Field(default_factory=list, description="Most active folders")
242255

243256
@field_serializer("last_activity")
@@ -253,7 +266,9 @@ class ProjectActivitySummary(BaseModel):
253266
)
254267
summary: ActivityStats
255268
timeframe: str = Field(description="The timeframe used for the query")
256-
generated_at: datetime
269+
generated_at: Annotated[
270+
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
271+
]
257272
guidance: Optional[str] = Field(
258273
default=None, description="Assistant guidance for project selection and session management"
259274
)

test-int/mcp/test_build_context_validation.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async def test_build_context_empty_urls_fail_validation(mcp_server, app, test_pr
7070
"""Test that empty or whitespace-only URLs fail validation."""
7171

7272
async with Client(mcp_server) as client:
73-
# These should fail MinLen validation
73+
# These should fail validation
7474
empty_urls = [
7575
"", # Empty string
7676
" ", # Whitespace only
@@ -83,10 +83,9 @@ async def test_build_context_empty_urls_fail_validation(mcp_server, app, test_pr
8383
)
8484

8585
error_message = str(exc_info.value)
86-
# Should fail with validation error (either MinLen or our custom validation)
86+
# Should fail with validation error
8787
assert (
88-
"at least 1" in error_message
89-
or "too_short" in error_message
88+
"cannot be empty" in error_message
9089
or "empty or whitespace" in error_message
9190
or "value_error" in error_message
9291
or "should be non-empty" in error_message

tests/schemas/test_memory_url.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for MemoryUrl parsing."""
22

3+
import pytest
4+
35
from basic_memory.schemas.memory import memory_url, memory_url_path, normalize_memory_url
46

57

@@ -59,5 +61,6 @@ def test_normalize_memory_url_no_prefix():
5961

6062

6163
def test_normalize_memory_url_empty():
62-
"""Test converting back to string."""
63-
assert normalize_memory_url("") == ""
64+
"""Test that empty string raises ValueError."""
65+
with pytest.raises(ValueError, match="cannot be empty"):
66+
normalize_memory_url("")

tests/schemas/test_memory_url_validation.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,11 @@ def test_valid_normalization(self):
113113
)
114114

115115
def test_empty_url(self):
116-
"""Test that empty URLs return empty string."""
117-
assert normalize_memory_url(None) == ""
118-
assert normalize_memory_url("") == ""
116+
"""Test that empty URLs raise ValueError."""
117+
with pytest.raises(ValueError, match="cannot be empty"):
118+
normalize_memory_url(None)
119+
with pytest.raises(ValueError, match="cannot be empty"):
120+
normalize_memory_url("")
119121

120122
def test_invalid_double_slashes(self):
121123
"""Test that URLs with double slashes raise ValueError."""
@@ -145,14 +147,14 @@ def test_invalid_protocol_schemes(self):
145147

146148
def test_whitespace_only(self):
147149
"""Test that whitespace-only URLs raise ValueError."""
148-
invalid_urls = [
150+
whitespace_urls = [
149151
" ",
150152
"\t",
151153
"\n",
152154
" \n ",
153155
]
154156

155-
for url in invalid_urls:
157+
for url in whitespace_urls:
156158
with pytest.raises(ValueError, match="cannot be empty or whitespace"):
157159
normalize_memory_url(url)
158160

@@ -206,9 +208,9 @@ def test_invalid_urls_fail_validation(self):
206208
error_msg = str(exc_info.value)
207209
assert "value_error" in error_msg, f"Should be a value_error for '{url}'"
208210

209-
def test_empty_string_fails_minlength(self):
210-
"""Test that empty strings fail MinLen validation."""
211-
with pytest.raises(ValidationError, match="at least 1"):
211+
def test_empty_string_fails_validation(self):
212+
"""Test that empty strings fail validation."""
213+
with pytest.raises(ValidationError, match="cannot be empty"):
212214
memory_url.validate_python("")
213215

214216
def test_very_long_urls_fail_maxlength(self):

0 commit comments

Comments
 (0)