Skip to content

Commit fd1a864

Browse files
committed
test(19-03): add failing SEP-2549 cache-hint + ordering-guard tests
- Default ttl=0 leaves the server cache-hint map empty - ttl=300 stamps the probed per-method surface (tools/list, private scope) - Write-tool exclusion pinned on the installed SDK shape: tools/call is not a CacheableMethod (validate_cache_hints rejects it); no tools/call result exposes ttl_ms/cache_scope (guards pass by design in RED) - d5 deterministic registration order across two build_app invocations - load_config rejects negative cache_ttl_ms with SystemExit(2)
1 parent c8304e1 commit fd1a864

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

tests/test_mcp_cache_hints.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""SEP-2549 cache-hint tests (Phase 19, L2) + deterministic-ordering guard.
2+
3+
RED phase (TDD): written BEFORE the config key + stamping land. The config
4+
and gate tests fail with AttributeError / missing-SystemExit; the two SDK
5+
-shape guard tests (write tools never carry hints; deterministic order)
6+
pass by design in RED — they pin installed-SDK invariants the lever must
7+
not break.
8+
9+
PROBED API (installed mcp 2.0.0 — recorded per 19-03-PLAN Task 3 PROBE
10+
FIRST): cache hints are a PER-METHOD constructor map,
11+
``MCPServer(cache_hints={method: CacheHint(ttl_ms=..., scope=...)})`` from
12+
``mcp.server.caching``. ``CacheableMethod`` covers list/read/discovery
13+
methods only — ``tools/call`` is NOT cacheable (``validate_cache_hints``
14+
rejects the key; ``CallToolResult`` extends ``Result``, not
15+
``CacheableResult``, and carries no ``ttl_ms``/``cache_scope`` fields; the
16+
``@tool()`` decorator has no per-tool hint parameter). supamem serves no
17+
prompts/resources, so the one usable stampable surface is ``tools/list``:
18+
a TTL there lets 2026-era clients cache the tool registry (d5 ordering
19+
hygiene makes that cache meaningful). Per-tool search-result stamping —
20+
the shape RESEARCH §3.3 L2 assumed — does not exist in this SDK version;
21+
the write-tool exclusion guarantee is therefore airtight by construction
22+
(no tools/call result is ever stampable at all).
23+
"""
24+
from __future__ import annotations
25+
26+
from pathlib import Path
27+
28+
import pytest
29+
from mcp.server.mcpserver import Context
30+
31+
from tests.conftest import _cfg_with_caps, _mock_backend_with_long_chunks
32+
33+
from supamem.mcp_server import build_app
34+
35+
EXPECTED_TOOL_ORDER = [
36+
"dual_memory_search",
37+
"dual_memory_write",
38+
"qdrant_find",
39+
"qdrant_store",
40+
]
41+
42+
43+
def _server_cache_hints(app: object) -> dict:
44+
"""Read the validated per-method hint map off the installed MCPServer."""
45+
server = getattr(app, "_lowlevel_server")
46+
return dict(getattr(server, "cache_hints"))
47+
48+
49+
# ── 1. Default OFF: no cache-hint surface anywhere ──────────────────────────
50+
51+
52+
def test_default_ttl_zero_no_hints() -> None:
53+
from supamem.config import ResolvedConfig
54+
55+
assert ResolvedConfig().mcp_cache_ttl_ms == 0, "cache_ttl_ms must default to 0 (off)"
56+
57+
app = build_app(_cfg_with_caps())
58+
assert _server_cache_hints(app) == {}, (
59+
f"ttl=0 must leave the server's cache-hint map empty, got "
60+
f"{_server_cache_hints(app)!r}"
61+
)
62+
63+
64+
# ── 2. Enabled: the stampable surface carries the configured TTL ───────────
65+
66+
67+
def test_ttl_enabled_stamps_cacheable_surface() -> None:
68+
from mcp.server.caching import CacheHint
69+
70+
cfg = _cfg_with_caps(mcp_cache_ttl_ms=300)
71+
app = build_app(cfg)
72+
hints = _server_cache_hints(app)
73+
assert hints == {"tools/list": CacheHint(ttl_ms=300, scope="private")}, (
74+
f"ttl=300 must stamp the one cacheable method supamem serves "
75+
f"(tools/list, private scope), got: {hints!r}"
76+
)
77+
78+
79+
# ── 3. Write tools never carry cache hints (stale write-then-read guard) ───
80+
81+
82+
def test_tools_call_results_never_carry_hints() -> None:
83+
"""tools/call is not stampable at all: SDK rejects the key; results have
84+
no ttl fields. A cached write result could mask write-then-read
85+
visibility — the SDK shape makes the exclusion airtight for every tool."""
86+
from mcp.server.caching import CacheHint, validate_cache_hints
87+
88+
with pytest.raises(ValueError, match="cacheable methods"):
89+
validate_cache_hints({"tools/call": CacheHint(ttl_ms=300)})
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_no_tool_result_exposes_ttl_fields(
94+
monkeypatch: pytest.MonkeyPatch,
95+
) -> None:
96+
from supamem.memory_writer import WriteResult
97+
98+
_mock_backend_with_long_chunks(monkeypatch, n_hits=1, text_len=80)
99+
100+
def _fake_write_memory(**_kwargs: object) -> WriteResult:
101+
return WriteResult(
102+
summary="saved",
103+
path="/tmp/x.md",
104+
topic="x",
105+
slug="x",
106+
indexed=True,
107+
points_added=1,
108+
error=None,
109+
)
110+
111+
from supamem import memory_writer
112+
113+
monkeypatch.setattr(memory_writer, "write_memory", _fake_write_memory)
114+
115+
cfg = _cfg_with_caps(mcp_cache_ttl_ms=300)
116+
app = build_app(cfg)
117+
calls = {
118+
"dual_memory_search": {"query": "hi", "top_k": 1},
119+
"qdrant_find": {"query": "hi", "top_k": 1},
120+
"dual_memory_write": {"topic": "x", "content": "body"},
121+
"qdrant_store": {"topic": "x", "content": "body"},
122+
}
123+
for name, args in calls.items():
124+
result = await app._tool_manager.call_tool( # type: ignore[attr-defined]
125+
name, args, Context(), convert_result=True
126+
)
127+
assert not hasattr(result, "ttl_ms"), (
128+
f"{name}: tools/call result must not expose ttl_ms (no stale "
129+
f"write-then-read window)"
130+
)
131+
assert not hasattr(result, "cache_scope"), (
132+
f"{name}: tools/call result must not expose cache_scope"
133+
)
134+
135+
136+
# ── 4. d5 guard: deterministic registration order across invocations ───────
137+
138+
139+
def test_deterministic_tool_registration_order(
140+
monkeypatch: pytest.MonkeyPatch,
141+
) -> None:
142+
monkeypatch.setenv("SUPAMEM_QDRANT_ALIASES", "1")
143+
app_a = build_app(_cfg_with_caps())
144+
app_b = build_app(_cfg_with_caps())
145+
order_a = list(app_a._tool_manager._tools) # type: ignore[attr-defined]
146+
order_b = list(app_b._tool_manager._tools) # type: ignore[attr-defined]
147+
assert order_a == order_b, (
148+
f"registration order must be deterministic (prompt-cache hygiene d5): "
149+
f"{order_a} != {order_b}"
150+
)
151+
assert order_a == EXPECTED_TOOL_ORDER, (
152+
f"fixed module registration order expected, got: {order_a}"
153+
)
154+
155+
156+
# ── 5. Validation gate: negative ttl fails closed at boot ──────────────────
157+
158+
159+
def test_load_config_rejects_negative_ttl(
160+
tmp_path: Path,
161+
monkeypatch: pytest.MonkeyPatch,
162+
capsys: pytest.CaptureFixture[str],
163+
) -> None:
164+
from supamem.config import load_config
165+
166+
monkeypatch.delenv("SUPAMEM_CONFIG", raising=False)
167+
cfg_dir = tmp_path / ".supamem"
168+
cfg_dir.mkdir()
169+
(cfg_dir / "config.toml").write_text(
170+
"[supamem.mcp]\ncache_ttl_ms = -1\n",
171+
encoding="utf-8",
172+
)
173+
with pytest.raises(SystemExit) as exc:
174+
load_config(tmp_path)
175+
assert exc.value.code == 2
176+
captured = capsys.readouterr()
177+
assert "cache_ttl_ms" in captured.err, (
178+
f"error must name the offending key, got: {captured.err!r}"
179+
)

0 commit comments

Comments
 (0)