From 08c2868ec572d0060bf46c26fe4f5b42c111fec9 Mon Sep 17 00:00:00 2001 From: Jiahao Ren Date: Thu, 30 Apr 2026 11:06:26 +0000 Subject: [PATCH 1/3] fix(kg): validate ISO-8601 date formats in temporal parameters at MCP boundary Add validate_iso_date() to config.py and apply it at the MCP boundary in tool_kg_query (as_of), tool_kg_add (valid_from), and tool_kg_invalidate (ended). Previously, non-ISO date strings like 'March 2026' or 'Jan 2025' were forwarded to SQLite without validation, silently producing empty result sets instead of matching facts. Now the MCP tools reject malformed dates early with a clear error message. Fixes #1164 --- mempalace/config.py | 27 +++++++++++++++ mempalace/mcp_server.py | 6 +++- tests/test_config.py | 77 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/mempalace/config.py b/mempalace/config.py index cacd1f9184..f7966a3575 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -92,6 +92,33 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str: return value +# ── ISO-8601 date validation ───────────────────────────────────────────────── +# Accepts YYYY, YYYY-MM, or YYYY-MM-DD. Used at the MCP boundary so that +# invalid date strings are rejected early instead of silently producing +# empty knowledge-graph query results. + +_ISO_DATE_RE = re.compile(r"^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$") + + +def validate_iso_date(value: str | None, param_name: str = "date") -> str | None: + """Validate an optional ISO-8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD). + + Returns the value unchanged if valid (or ``None``). Raises ``ValueError`` + with a user-facing message if the format is unrecognised. + """ + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + return None + value = value.strip() + if not _ISO_DATE_RE.match(value): + raise ValueError( + f"{param_name}={value!r} is not a valid ISO-8601 date " + f"(expected YYYY, YYYY-MM, or YYYY-MM-DD)" + ) + return value + + DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace") DEFAULT_COLLECTION_NAME = "mempalace_drawers" diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 43897c8ca3..7b1fbfefcb 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -55,6 +55,7 @@ sanitize_kg_value, sanitize_name, sanitize_content, + validate_iso_date, ) from .version import __version__ # noqa: E402 from .backends.chroma import ( # noqa: E402 @@ -646,7 +647,7 @@ def tool_check_duplicate(content: str, threshold: float = 0.9): "vector_disabled": True, "vector_disabled_reason": _vector_disabled_reason, "hint": ( - "duplicate detection requires vector search; run " "`mempalace repair` to restore" + "duplicate detection requires vector search; run `mempalace repair` to restore" ), } try: @@ -1021,6 +1022,7 @@ def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"): """Query the knowledge graph for an entity's relationships.""" try: entity = sanitize_kg_value(entity, "entity") + as_of = validate_iso_date(as_of, "as_of") except ValueError as e: return {"error": str(e)} if direction not in ("outgoing", "incoming", "both"): @@ -1037,6 +1039,7 @@ def tool_kg_add( subject = sanitize_kg_value(subject, "subject") predicate = sanitize_name(predicate, "predicate") object = sanitize_kg_value(object, "object") + valid_from = validate_iso_date(valid_from, "valid_from") except ValueError as e: return {"success": False, "error": str(e)} @@ -1062,6 +1065,7 @@ def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = N subject = sanitize_kg_value(subject, "subject") predicate = sanitize_name(predicate, "predicate") object = sanitize_kg_value(object, "object") + ended = validate_iso_date(ended, "ended") except ValueError as e: return {"success": False, "error": str(e)} _wal_log( diff --git a/tests/test_config.py b/tests/test_config.py index d7707d9829..da4d324cbd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,13 @@ import tempfile import pytest -from mempalace.config import MempalaceConfig, normalize_wing_name, sanitize_kg_value, sanitize_name +from mempalace.config import ( + MempalaceConfig, + normalize_wing_name, + sanitize_kg_value, + sanitize_name, + validate_iso_date, +) def test_default_config(): @@ -212,3 +218,72 @@ def test_kg_value_rejects_null_bytes(): def test_kg_value_rejects_over_length(): with pytest.raises(ValueError): sanitize_kg_value("a" * 129) + + +# --- validate_iso_date --- + + +def test_validate_iso_date_accepts_none(): + assert validate_iso_date(None) is None + + +def test_validate_iso_date_accepts_empty_string(): + assert validate_iso_date("") is None + + +def test_validate_iso_date_accepts_whitespace_only(): + assert validate_iso_date(" ") is None + + +def test_validate_iso_date_accepts_full_date(): + assert validate_iso_date("2025-01-01") == "2025-01-01" + + +def test_validate_iso_date_accepts_year_month(): + assert validate_iso_date("2025-01") == "2025-01" + + +def test_validate_iso_date_accepts_year_only(): + assert validate_iso_date("2025") == "2025" + + +def test_validate_iso_date_strips_whitespace(): + assert validate_iso_date(" 2025-06-15 ") == "2025-06-15" + + +def test_validate_iso_date_rejects_natural_language(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("March 2026") + + +def test_validate_iso_date_rejects_partial_date(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025-1") + + +def test_validate_iso_date_rejects_invalid_month(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025-13-01") + + +def test_validate_iso_date_rejects_invalid_day(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025-02-32") + + +def test_validate_iso_date_rejects_garbage(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("not-a-date") + + +def test_validate_iso_date_custom_param_name(): + with pytest.raises(ValueError, match="as_of="): + validate_iso_date("Jan 2025", "as_of") + + +def test_validate_iso_date_edge_case_december(): + assert validate_iso_date("2025-12-31") == "2025-12-31" + + +def test_validate_iso_date_edge_case_january(): + assert validate_iso_date("2025-01-01") == "2025-01-01" From 43b82af53432f352f3dfac082683df1e4a4b7ab8 Mon Sep 17 00:00:00 2001 From: Jiahao Ren Date: Sat, 2 May 2026 03:25:16 +0000 Subject: [PATCH 2/3] fix: add from __future__ import annotations for Python 3.9 compat The validate_iso_date function uses PEP 604 union syntax (str | None) which requires Python 3.10+ at runtime. Adding future annotations import makes it work on the project's minimum supported Python 3.9. Addresses Qodo-Free-For-OSS review comment. --- mempalace/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mempalace/config.py b/mempalace/config.py index f7966a3575..35bf85c251 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -4,6 +4,8 @@ Priority: env vars > config file (~/.mempalace/config.json) > defaults """ +from __future__ import annotations + import json import os import re From 466f6fb67e2fc29487b6432e84ffb5f50777b1e6 Mon Sep 17 00:00:00 2001 From: Jiahao Ren Date: Sun, 3 May 2026 04:57:38 +0000 Subject: [PATCH 3/3] fix: use Optional[str] instead of PEP 604 union for Python 3.9 compat Replace with in validate_iso_date() to avoid potential SyntaxError on Python 3.9, even though is present. Some type-checking tools and runtime introspection may still evaluate annotations. Also add 11 new edge-case tests: - Leap year / non-leap year Feb 29 - Rejected formats: slash, dot, datetime, month 0, day 0 - Non-string input handling - Param name in error messages Ref: Qodo review on #1283 --- mempalace/config.py | 3 ++- tests/test_config.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/mempalace/config.py b/mempalace/config.py index 35bf85c251..4992ee0d92 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -10,6 +10,7 @@ import os import re from pathlib import Path +from typing import Optional # ── Input validation ────────────────────────────────────────────────────────── @@ -102,7 +103,7 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str: _ISO_DATE_RE = re.compile(r"^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$") -def validate_iso_date(value: str | None, param_name: str = "date") -> str | None: +def validate_iso_date(value: Optional[str], param_name: str = "date") -> Optional[str]: """Validate an optional ISO-8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD). Returns the value unchanged if valid (or ``None``). Raises ``ValueError`` diff --git a/tests/test_config.py b/tests/test_config.py index da4d324cbd..90841944ef 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -287,3 +287,64 @@ def test_validate_iso_date_edge_case_december(): def test_validate_iso_date_edge_case_january(): assert validate_iso_date("2025-01-01") == "2025-01-01" + + +def test_validate_iso_date_leap_year(): + assert validate_iso_date("2024-02-29") == "2024-02-29" + + +def test_validate_iso_date_non_leap_year_feb_29(): + """2025 is not a leap year — Feb 29 should still pass regex (no calendar check).""" + # The regex validates format, not calendar correctness. + # This is intentional — calendar validation is out of scope. + assert validate_iso_date("2025-02-29") == "2025-02-29" + + +def test_validate_iso_date_rejects_slash_format(): + """YYYY/MM/DD is not ISO-8601.""" + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025/01/01") + + +def test_validate_iso_date_rejects_dot_format(): + """DD.MM.YYYY is not ISO-8601.""" + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("01.01.2025") + + +def test_validate_iso_date_rejects_time_component(): + """ISO-8601 datetime with time should be rejected (date only).""" + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025-01-01T12:00:00") + + +def test_validate_iso_date_rejects_month_zero(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025-00-01") + + +def test_validate_iso_date_rejects_day_zero(): + with pytest.raises(ValueError, match="not a valid ISO-8601"): + validate_iso_date("2025-01-00") + + +def test_validate_iso_date_accepts_max_month_day(): + assert validate_iso_date("2025-12-31") == "2025-12-31" + + +def test_validate_iso_date_non_string_input(): + """Non-string input should return None (treated as missing).""" + assert validate_iso_date(123) is None + assert validate_iso_date(3.14) is None + + +def test_validate_iso_date_param_name_in_error(): + """Custom param name appears in the error message.""" + with pytest.raises(ValueError, match="ended="): + validate_iso_date("bad-date", "ended") + + +def test_validate_iso_date_param_name_default(): + """Default param name is 'date'.""" + with pytest.raises(ValueError, match="date="): + validate_iso_date("bad-date")