Skip to content

Commit 785cb55

Browse files
fix(api): return 400 for malformed memory-context locators
Malformed memory-context locators raised ValueError from normalization and escaped the v2 route as HTTP 500. Reject them at the API boundary with a bounded 400 while preserving the normalizer reason and keeping genuine context-service failures visible. Add API and MCP boundary regressions. Refs #1879. Signed-off-by: Steven Huang <[email protected]>
1 parent 0884e20 commit 785cb55

3 files changed

Lines changed: 294 additions & 46 deletions

File tree

‎src/basic_memory/api/v2/routers/memory_router.py‎

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -187,49 +187,82 @@ async def get_memory_context(
187187
action="build_context",
188188
page=page,
189189
page_size=page_size,
190-
):
191-
logger.debug(
192-
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
193-
)
194-
memory_url = normalize_memory_url(uri)
190+
) as span:
191+
try:
192+
logger.debug(
193+
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
194+
)
195+
try:
196+
memory_url = normalize_memory_url(uri)
197+
except ValueError as exc:
198+
# Trigger: the path is not a valid memory locator (embedded scheme,
199+
# doubled separator, invalid character, empty/whitespace).
200+
# Why: normalize_memory_url raises ValueError for caller input; letting
201+
# it escape reported a client mistake as an internal server error (#1879).
202+
# Outcome: a bounded 400 with the normalizer's specific reason before
203+
# any context traversal.
204+
raise HTTPException(
205+
status_code=status.HTTP_400_BAD_REQUEST,
206+
detail=str(exc),
207+
) from exc
195208

196-
since = parse_timeframe(timeframe) if timeframe else None
197-
limit = page_size
198-
offset = (page - 1) * page_size
209+
since = parse_timeframe(timeframe) if timeframe else None
210+
limit = page_size
211+
offset = (page - 1) * page_size
199212

200-
with logfire.span(
201-
"api.memory.build_context.build_context",
202-
domain="memory",
203-
action="build_context",
204-
phase="build_context",
205-
page=page,
206-
page_size=page_size,
207-
):
208-
try:
209-
context = await context_service.build_context(
210-
memory_url,
211-
depth=depth,
212-
since=since,
213-
limit=limit,
214-
offset=offset,
215-
max_related=max_related,
216-
include_observations=include_observations,
217-
)
218-
except AmbiguousIdentifierError as exc:
219-
# A title URL that names several notes has no single context to build.
220-
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
221-
with logfire.span(
222-
"api.memory.build_context.shape_response",
223-
domain="memory",
224-
action="build_context",
225-
phase="shape_response",
226-
result_count=len(context.results),
227-
):
228-
async with db.scoped_session(session_maker) as session:
229-
return await to_graph_context(
230-
context,
231-
entity_repository=entity_repository,
232-
session=session,
233-
page=page,
234-
page_size=page_size,
235-
)
213+
with logfire.span(
214+
"api.memory.build_context.build_context",
215+
domain="memory",
216+
action="build_context",
217+
phase="build_context",
218+
page=page,
219+
page_size=page_size,
220+
):
221+
try:
222+
context = await context_service.build_context(
223+
memory_url,
224+
depth=depth,
225+
since=since,
226+
limit=limit,
227+
offset=offset,
228+
max_related=max_related,
229+
include_observations=include_observations,
230+
)
231+
except AmbiguousIdentifierError as exc:
232+
# A title URL that names several notes has no single context to build.
233+
raise HTTPException(
234+
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
235+
) from exc
236+
with logfire.span(
237+
"api.memory.build_context.shape_response",
238+
domain="memory",
239+
action="build_context",
240+
phase="shape_response",
241+
result_count=len(context.results),
242+
):
243+
async with db.scoped_session(session_maker) as session:
244+
result = await to_graph_context(
245+
context,
246+
entity_repository=entity_repository,
247+
session=session,
248+
page=page,
249+
page_size=page_size,
250+
)
251+
except HTTPException as exc:
252+
# Malformed input (400) and ambiguous titles (409) are both caller-visible
253+
# outcomes; only a server-side status would be a failure.
254+
span.set_attribute(
255+
"outcome",
256+
"input_rejected" if exc.status_code < 500 else "failed",
257+
)
258+
raise
259+
except Exception:
260+
# Trigger: the context service or response shaping raised.
261+
# Why: genuine failures must stay visible instead of being flattened into
262+
# a validation response.
263+
# Outcome: the 500 path records a failed terminal outcome.
264+
span.set_attribute("outcome", "failed")
265+
raise
266+
267+
span.set_attribute("outcome", "success")
268+
return result

‎tests/api/v2/test_memory_router.py‎

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,89 @@
11
"""Tests for v2 memory router endpoints."""
22

3-
import pytest
4-
from httpx import AsyncClient
3+
import importlib
4+
from contextlib import contextmanager
55
from pathlib import Path
6+
from types import SimpleNamespace
7+
from typing import Any
8+
9+
import pytest
10+
from fastapi import FastAPI
11+
from httpx import ASGITransport, AsyncClient
612

713
from basic_memory import db
14+
from basic_memory.deps.services import get_context_service_v2_external
815
from basic_memory.models import Project
916
from basic_memory.schemas.memory import (
1017
MAX_CONTEXT_DEPTH,
1118
MAX_CONTEXT_PAGE_SIZE,
1219
MAX_CONTEXT_RELATED_RESULTS,
1320
MAX_RECENT_ACTIVITY_PAGE_SIZE,
1421
)
22+
from basic_memory.services.exceptions import AmbiguousIdentifierError
23+
24+
memory_router_module = importlib.import_module("basic_memory.api.v2.routers.memory_router")
25+
26+
27+
class _RecordingSpan:
28+
"""Capture the attributes the memory router ships on its root span."""
29+
30+
def __init__(self, name: str, attributes: dict[str, Any]) -> None:
31+
self.name = name
32+
self.attributes = attributes
33+
34+
def set_attribute(self, key: str, value: Any) -> None:
35+
self.attributes[key] = value
36+
37+
def set_attributes(self, values: dict[str, Any]) -> None:
38+
self.attributes.update(values)
39+
40+
def __enter__(self) -> "_RecordingSpan":
41+
return self
42+
43+
def __exit__(self, *exc_info: object) -> None:
44+
return None
45+
46+
47+
@contextmanager
48+
def _record_memory_router_spans(monkeypatch):
49+
spans: list[_RecordingSpan] = []
50+
51+
def span(name: str, **attributes: Any) -> _RecordingSpan:
52+
recorded = _RecordingSpan(name, dict(attributes))
53+
spans.append(recorded)
54+
return recorded
55+
56+
monkeypatch.setattr(memory_router_module, "logfire", SimpleNamespace(span=span))
57+
yield spans
58+
59+
60+
def _root_memory_span(spans: list[_RecordingSpan]) -> _RecordingSpan:
61+
return next(span for span in spans if span.name == "api.request.memory.build_context")
62+
63+
64+
class _TraversalSpy:
65+
def __init__(self) -> None:
66+
self.calls = 0
67+
68+
async def build_context(self, *args: Any, **kwargs: Any) -> None:
69+
self.calls += 1
70+
raise AssertionError("context traversal must not run for a rejected locator")
71+
72+
73+
class _ExplodingContextService:
74+
async def build_context(self, *args: Any, **kwargs: Any) -> None:
75+
raise RuntimeError("context backend exploded")
76+
77+
78+
class _AmbiguousContextService:
79+
async def build_context(self, *args: Any, **kwargs: Any) -> None:
80+
raise AmbiguousIdentifierError(
81+
"Bean Origins",
82+
[
83+
("bean-origins", "Bean Origins.md"),
84+
("archive/bean-origins", "archive/Bean Origins.md"),
85+
],
86+
)
1587

1688

1789
async def create_test_entity(
@@ -449,3 +521,126 @@ async def test_get_memory_context_skips_observations_when_asked(
449521
== full_data["results"][0]["primary_result"]["permalink"]
450522
)
451523
assert shape_data["results"][0]["related_results"] == full_data["results"][0]["related_results"]
524+
525+
526+
@pytest.mark.asyncio
527+
@pytest.mark.parametrize(
528+
("locator", "expected_detail"),
529+
[
530+
# The production locator shape: an arbitrary HTTPS URL embedded in a path.
531+
("workspace/project/https://example.invalid/notes", "contains protocol scheme"),
532+
("", "cannot be empty"),
533+
("%20%20%20", "cannot be empty or whitespace"),
534+
("notes%3Cbrackets%3E", "contains invalid characters"),
535+
("notes//meeting", "contains double slashes"),
536+
],
537+
)
538+
async def test_get_memory_context_rejects_malformed_locators(
539+
app: FastAPI,
540+
client: AsyncClient,
541+
v2_project_url: str,
542+
locator: str,
543+
expected_detail: str,
544+
):
545+
"""Malformed locators are caller input, not an internal server error (#1879)."""
546+
traversal = _TraversalSpy()
547+
app.dependency_overrides[get_context_service_v2_external] = lambda: traversal
548+
try:
549+
response = await client.get(f"{v2_project_url}/memory/{locator}")
550+
finally:
551+
app.dependency_overrides.pop(get_context_service_v2_external, None)
552+
553+
assert response.status_code == 400
554+
assert expected_detail in response.json()["detail"]
555+
# The boundary rejects before the context service is touched.
556+
assert traversal.calls == 0
557+
558+
559+
@pytest.mark.asyncio
560+
async def test_get_memory_context_accepts_relative_and_memory_url_locators(
561+
client: AsyncClient,
562+
test_graph,
563+
v2_project_url: str,
564+
):
565+
"""Both accepted locator spellings still resolve to the same context."""
566+
relative = await client.get(f"{v2_project_url}/memory/test/root")
567+
scheme = await client.get(f"{v2_project_url}/memory/memory://test/root")
568+
569+
assert relative.status_code == 200
570+
assert scheme.status_code == 200
571+
assert (
572+
relative.json()["results"][0]["primary_result"]["permalink"]
573+
== scheme.json()["results"][0]["primary_result"]["permalink"]
574+
)
575+
576+
577+
@pytest.mark.asyncio
578+
async def test_get_memory_context_records_input_rejected_outcome(
579+
client: AsyncClient,
580+
v2_project_url: str,
581+
monkeypatch,
582+
):
583+
"""The root span records a bounded terminal outcome for caller input rejection."""
584+
with _record_memory_router_spans(monkeypatch) as spans:
585+
response = await client.get(
586+
f"{v2_project_url}/memory/workspace/project/https://example.invalid/notes"
587+
)
588+
589+
assert response.status_code == 400
590+
root_span = _root_memory_span(spans)
591+
assert root_span.attributes["outcome"] == "input_rejected"
592+
# The span carries a bounded outcome, not the caller's uploaded locator content.
593+
assert "example.invalid" not in repr(root_span.attributes)
594+
595+
596+
@pytest.mark.asyncio
597+
async def test_get_memory_context_records_success_outcome(
598+
client: AsyncClient,
599+
test_graph,
600+
v2_project_url: str,
601+
monkeypatch,
602+
):
603+
with _record_memory_router_spans(monkeypatch) as spans:
604+
response = await client.get(f"{v2_project_url}/memory/test/root")
605+
606+
assert response.status_code == 200
607+
assert _root_memory_span(spans).attributes["outcome"] == "success"
608+
609+
610+
@pytest.mark.asyncio
611+
async def test_get_memory_context_keeps_service_failures_as_server_errors(
612+
app: FastAPI,
613+
v2_project_url: str,
614+
monkeypatch,
615+
):
616+
"""A genuine context-service failure stays visible as a 500 and a failed outcome."""
617+
app.dependency_overrides[get_context_service_v2_external] = lambda: _ExplodingContextService()
618+
try:
619+
with _record_memory_router_spans(monkeypatch) as spans:
620+
async with AsyncClient(
621+
transport=ASGITransport(app=app, raise_app_exceptions=False),
622+
base_url="http://test",
623+
) as failing_client:
624+
response = await failing_client.get(f"{v2_project_url}/memory/test/root")
625+
finally:
626+
app.dependency_overrides.pop(get_context_service_v2_external, None)
627+
628+
assert response.status_code == 500
629+
assert _root_memory_span(spans).attributes["outcome"] == "failed"
630+
631+
632+
@pytest.mark.asyncio
633+
async def test_get_memory_context_preserves_ambiguous_identifier_conflict(
634+
app: FastAPI,
635+
client: AsyncClient,
636+
v2_project_url: str,
637+
):
638+
"""Ambiguous identifiers keep their existing 409 conflict response."""
639+
app.dependency_overrides[get_context_service_v2_external] = lambda: _AmbiguousContextService()
640+
try:
641+
response = await client.get(f"{v2_project_url}/memory/Bean%20Origins")
642+
finally:
643+
app.dependency_overrides.pop(get_context_service_v2_external, None)
644+
645+
assert response.status_code == 409
646+
assert "Ambiguous identifier 'Bean Origins'" in response.json()["detail"]

‎tests/mcp/test_tool_bm_context.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
from typing import Any
77

88
import pytest
9+
from fastmcp import Client
10+
from fastmcp.exceptions import ToolError
911

12+
from basic_memory.mcp.server import mcp
1013
from basic_memory.mcp.tools import bm_context, write_note
1114

1215

@@ -133,3 +136,20 @@ async def test_text_output_names_the_next_page(client, test_project):
133136
assert isinstance(text, str)
134137
assert "More results available. Call bm_context(identifier='graph/*'" in text
135138
assert "page=2, page_size=1)" in text
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_malformed_locator_is_actionable_input_feedback(client, test_project):
143+
"""The production-shaped locator must not surface as an opaque server failure (#1879)."""
144+
locator = "workspace/project/https://example.invalid/notes"
145+
146+
async with Client(mcp) as mcp_client:
147+
with pytest.raises(ToolError) as exc_info:
148+
await mcp_client.call_tool(
149+
"bm_context",
150+
{"identifier": locator, "project": test_project.name},
151+
)
152+
153+
message = str(exc_info.value)
154+
assert "protocol scheme" in message
155+
assert "Internal server error" not in message

0 commit comments

Comments
 (0)