Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
Priority: env vars > config file (~/.mempalace/config.json) > defaults
"""

from __future__ import annotations

import json
import os
import re
from pathlib import Path
from typing import Optional


# ── Input validation ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -92,6 +95,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: 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``
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"

Expand Down
6 changes: 5 additions & 1 deletion mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"):
Expand All @@ -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)}

Expand All @@ -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(
Expand Down
138 changes: 137 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -212,3 +218,133 @@ 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"


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")