Skip to content

Commit 32a196d

Browse files
committed
fix(kg): validate ISO-8601 date formats at MCP boundary
tool_kg_query (as_of), tool_kg_add (valid_from), and tool_kg_invalidate (ended) accepted any string and forwarded it to SQLite without format validation. Parameterized queries prevent SQL injection, but invalid date strings silently produce empty result sets — callers cannot distinguish "no fact at this time" from "your date format was unrecognized." This is especially painful for natural-language LLM callers that synthesize dates like "March 2026" or "Jan 2025". Add sanitize_iso_date() in config.py alongside the other input validators. It accepts YYYY, YYYY-MM, and YYYY-MM-DD forms; passes through None/empty; and raises ValueError with a field-named message on anything else. Call it from the three kg MCP tool wrappers before values reach the storage layer so the caller gets a clear error instead of a silent miss. Closes #1164
1 parent 8ac98f0 commit 32a196d

4 files changed

Lines changed: 141 additions & 1 deletion

File tree

mempalace/config.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,34 @@ def sanitize_kg_value(value: str, field_name: str = "value") -> str:
7171
return value
7272

7373

74+
# ISO-8601 date validator for knowledge-graph temporal parameters
75+
# (as_of, valid_from, valid_to, ended). Parameterized queries already
76+
# prevent SQL injection, but unvalidated date strings silently miss
77+
# every row — callers cannot distinguish "no fact at this time" from
78+
# "your date format was unrecognized." Accept YYYY, YYYY-MM, YYYY-MM-DD.
79+
_ISO_DATE_RE = re.compile(r"^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$")
80+
81+
82+
def sanitize_iso_date(value, field_name: str = "date"):
83+
"""Validate an ISO-8601 date string, accepting None or empty as-is.
84+
85+
Accepts ``YYYY``, ``YYYY-MM``, or ``YYYY-MM-DD``. Raises ValueError
86+
on any other non-empty input so the MCP layer can surface a clear
87+
error to the caller instead of silently returning empty results.
88+
"""
89+
if value is None or value == "":
90+
return value
91+
if not isinstance(value, str):
92+
raise ValueError(f"{field_name} must be a string")
93+
value = value.strip()
94+
if not _ISO_DATE_RE.match(value):
95+
raise ValueError(
96+
f"{field_name}={value!r} is not a valid ISO-8601 date "
97+
f"(expected YYYY, YYYY-MM, or YYYY-MM-DD)"
98+
)
99+
return value
100+
101+
74102
def sanitize_content(value: str, max_length: int = 100_000) -> str:
75103
"""Validate drawer/diary content length."""
76104
if not isinstance(value, str) or not value.strip():

mempalace/mcp_server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
sanitize_kg_value,
5656
sanitize_name,
5757
sanitize_content,
58+
sanitize_iso_date,
5859
)
5960
from .version import __version__ # noqa: E402
6061
from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402
@@ -844,6 +845,7 @@ def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"):
844845
"""Query the knowledge graph for an entity's relationships."""
845846
try:
846847
entity = sanitize_kg_value(entity, "entity")
848+
as_of = sanitize_iso_date(as_of, "as_of")
847849
except ValueError as e:
848850
return {"error": str(e)}
849851
if direction not in ("outgoing", "incoming", "both"):
@@ -860,6 +862,7 @@ def tool_kg_add(
860862
subject = sanitize_kg_value(subject, "subject")
861863
predicate = sanitize_name(predicate, "predicate")
862864
object = sanitize_kg_value(object, "object")
865+
valid_from = sanitize_iso_date(valid_from, "valid_from")
863866
except ValueError as e:
864867
return {"success": False, "error": str(e)}
865868

@@ -885,6 +888,7 @@ def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = N
885888
subject = sanitize_kg_value(subject, "subject")
886889
predicate = sanitize_name(predicate, "predicate")
887890
object = sanitize_kg_value(object, "object")
891+
ended = sanitize_iso_date(ended, "ended")
888892
except ValueError as e:
889893
return {"success": False, "error": str(e)}
890894
_wal_log(

tests/test_config.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import tempfile
44

55
import pytest
6-
from mempalace.config import MempalaceConfig, sanitize_kg_value, sanitize_name
6+
from mempalace.config import MempalaceConfig, sanitize_iso_date, sanitize_kg_value, sanitize_name
77

88

99
def test_default_config():
@@ -117,3 +117,65 @@ def test_kg_value_rejects_null_bytes():
117117
def test_kg_value_rejects_over_length():
118118
with pytest.raises(ValueError):
119119
sanitize_kg_value("a" * 129)
120+
121+
122+
# --- sanitize_iso_date ---
123+
124+
125+
def test_iso_date_accepts_year_only():
126+
assert sanitize_iso_date("2026") == "2026"
127+
128+
129+
def test_iso_date_accepts_year_month():
130+
assert sanitize_iso_date("2026-03") == "2026-03"
131+
132+
133+
def test_iso_date_accepts_full_date():
134+
assert sanitize_iso_date("2026-03-15") == "2026-03-15"
135+
136+
137+
def test_iso_date_passes_through_none():
138+
assert sanitize_iso_date(None) is None
139+
140+
141+
def test_iso_date_passes_through_empty_string():
142+
assert sanitize_iso_date("") == ""
143+
144+
145+
def test_iso_date_strips_whitespace():
146+
assert sanitize_iso_date(" 2026-03-15 ") == "2026-03-15"
147+
148+
149+
def test_iso_date_rejects_natural_language():
150+
with pytest.raises(ValueError):
151+
sanitize_iso_date("March 2026")
152+
153+
154+
def test_iso_date_rejects_abbreviated_month():
155+
with pytest.raises(ValueError):
156+
sanitize_iso_date("Jan 2025")
157+
158+
159+
def test_iso_date_rejects_us_format():
160+
with pytest.raises(ValueError):
161+
sanitize_iso_date("03/15/2026")
162+
163+
164+
def test_iso_date_rejects_invalid_month():
165+
with pytest.raises(ValueError):
166+
sanitize_iso_date("2026-13")
167+
168+
169+
def test_iso_date_rejects_invalid_day():
170+
with pytest.raises(ValueError):
171+
sanitize_iso_date("2026-02-32")
172+
173+
174+
def test_iso_date_rejects_non_string():
175+
with pytest.raises(ValueError):
176+
sanitize_iso_date(20260315)
177+
178+
179+
def test_iso_date_error_names_field():
180+
with pytest.raises(ValueError, match="valid_from"):
181+
sanitize_iso_date("yesterday", "valid_from")

tests/test_mcp_server.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,52 @@ def test_kg_stats(self, monkeypatch, config, palace_path, seeded_kg):
665665
result = tool_kg_stats()
666666
assert result["entities"] >= 4
667667

668+
# --- Date validation at the MCP boundary (issue #1164) ---
669+
670+
def test_kg_add_rejects_invalid_valid_from(self, monkeypatch, config, palace_path, kg):
671+
_patch_mcp_server(monkeypatch, config, kg)
672+
from mempalace.mcp_server import tool_kg_add
673+
674+
result = tool_kg_add(
675+
subject="Alice",
676+
predicate="likes",
677+
object="coffee",
678+
valid_from="Jan 2025",
679+
)
680+
assert result["success"] is False
681+
assert "valid_from" in result["error"]
682+
assert "ISO-8601" in result["error"]
683+
684+
def test_kg_query_rejects_invalid_as_of(self, monkeypatch, config, palace_path, seeded_kg):
685+
_patch_mcp_server(monkeypatch, config, seeded_kg)
686+
from mempalace.mcp_server import tool_kg_query
687+
688+
result = tool_kg_query(entity="Max", as_of="March 2026")
689+
assert "error" in result
690+
assert "as_of" in result["error"]
691+
692+
def test_kg_invalidate_rejects_invalid_ended(self, monkeypatch, config, palace_path, seeded_kg):
693+
_patch_mcp_server(monkeypatch, config, seeded_kg)
694+
from mempalace.mcp_server import tool_kg_invalidate
695+
696+
result = tool_kg_invalidate(
697+
subject="Max",
698+
predicate="does",
699+
object="chess",
700+
ended="yesterday",
701+
)
702+
assert result["success"] is False
703+
assert "ended" in result["error"]
704+
705+
def test_kg_query_accepts_partial_iso_dates(self, monkeypatch, config, palace_path, seeded_kg):
706+
_patch_mcp_server(monkeypatch, config, seeded_kg)
707+
from mempalace.mcp_server import tool_kg_query
708+
709+
# YYYY and YYYY-MM are valid ISO-8601 forms — must not be rejected.
710+
for value in ("2026", "2026-03", "2026-03-15"):
711+
result = tool_kg_query(entity="Max", as_of=value)
712+
assert "error" not in result, f"rejected valid date {value!r}: {result}"
713+
668714

669715
# ── Diary Tools ─────────────────────────────────────────────────────────
670716

0 commit comments

Comments
 (0)