From 8bea9e83af7806b8e9ad94d2ed6777e713e329d5 Mon Sep 17 00:00:00 2001 From: RightL Date: Tue, 18 Aug 2026 13:21:49 +0800 Subject: [PATCH 1/2] feat: add local MCP adapter --- docs/MCP.md | 55 ++++ pyproject.toml | 1 + rightmemory/entrypoint.py | 13 + rightmemory/mcp.py | 281 ++++++++++++++++++ rightmemory/update_alerts.py | 95 ++++++ .../SKILL.md | 16 +- tests/test_mcp.py | 254 ++++++++++++++++ 7 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 docs/MCP.md create mode 100644 rightmemory/mcp.py create mode 100644 rightmemory/update_alerts.py create mode 100644 tests/test_mcp.py diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..dbe3431 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,55 @@ +# RightMemory MCP + +RightMemory exposes a local MCP stdio server for ordinary agent work: + +```bash +rightmemory mcp +``` + +The server resolves its Memory root once at startup through the same rules as the CLI: + +1. an explicit `--profile`; +2. the nearest project `.rightmemory-profile`; +3. `RIGHTMEMORY_ROOT`; +4. the default RightMemory root. + +Use an explicit profile when the MCP host does not launch the server from the project directory: + +```bash +rightmemory --profile my-project mcp +``` + +A typical MCP host entry is: + +```json +{ + "mcpServers": { + "rightmemory": { + "command": "rightmemory", + "args": ["mcp"] + } + } +} +``` + +For a named profile, use `"args": ["--profile", "my-project", "mcp"]`. + +## Ordinary-agent tools + +The server exposes exactly three tools: + +- `rightmemory_retrieve` retrieves cross-session context when it could materially affect the current work. +- `rightmemory_submit_update` submits durable Memory or Pursuit evidence to the asynchronous unified Update queue. +- `rightmemory_capture_guidance` captures plausible reusable agent-behavior evidence, including explicit and implicit user redirections. + +The tool and parameter descriptions contain the complete automatic ordinary-agent contract. An MCP client should not also load the RightMemory orchestrator skill; that skill remains the CLI transport for clients without MCP support. + +Successful writes return no model-visible content. A write result contains text only when the agent must act, such as when evidence was saved but the Update worker could not start, or when queued work requires manual recovery. Update submission never reports synchronous semantic acceptance: the updater reconciles submitted evidence later and may change any relevant module or none. + +Guidance capture favors recall. A signal need not already be a fully settled general rule, and independent later occurrences of a similar pattern may be captured again. Capture does not replace applying the user's direction to the current work. + +## Scope + +The MCP adapter calls the same runtime, async Update store, and guidance capture implementation as the CLI. The CLI remains available for human inspection, explicit maintenance, queue status, retry, undo, and clients that cannot use MCP. + +This command currently serves stdio only. RightMemory does not expose a Streamable HTTP MCP endpoint. diff --git a/pyproject.toml b/pyproject.toml index 3f42ed7..dbc1aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ ] dependencies = [ "fastapi>=0.115.0", + "mcp>=1.28,<2", "pydantic-ai>=1.0.0", "uvicorn>=0.30.0", ] diff --git a/rightmemory/entrypoint.py b/rightmemory/entrypoint.py index 8e9ce97..65c2184 100644 --- a/rightmemory/entrypoint.py +++ b/rightmemory/entrypoint.py @@ -13,6 +13,19 @@ def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) profile_name, remaining = _parse_global_args(args) + if remaining[:1] == ["mcp"]: + try: + active = resolve_memory_root( + profile_name=profile_name, + cwd=Path.cwd(), + default_root=default_memory_root(), + ) + from .mcp import mcp_main + + return mcp_main(active.memory_root, remaining[1:]) + except (ValueError, ProfileError, FileNotFoundError, RuntimeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 if remaining[:1] == ["guidance"]: try: active = resolve_memory_root( diff --git a/rightmemory/mcp.py b/rightmemory/mcp.py new file mode 100644 index 0000000..952c403 --- /dev/null +++ b/rightmemory/mcp.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import argparse +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated, Protocol + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent +from pydantic import Field + +from .async_update import AsyncUpdateStore +from .config import load_config +from .guidance import submit_guidance +from .runtime import RightMemoryRuntime +from .update_alerts import collect_update_recovery_summary +from .update_queue import UpdateQueueStore + + +SESSION_ID_DESCRIPTION = ( + "A stable identifier chosen once for the current conversation and reused for every " + "RightMemory call in that conversation." +) +RETRIEVE_NEED_DESCRIPTION = ( + "A concise description of the cross-session context needed for the current work, " + "not a verbatim copy of the user's message." +) +UPDATE_EVIDENCE_DESCRIPTION = ( + "A self-contained account of what happened, what is currently true, and why " + "preserving it may matter in future work." +) +GUIDANCE_EVIDENCE_DESCRIPTION = ( + "A self-contained account of the agent-behavior signal. For a redirection, include " + "the prior approach or omission, the user's explicit or implicit signal, and the " + "resulting direction." +) + +RETRIEVE_DESCRIPTION = """\ +Retrieve cross-session context when it could materially change how the current work is +understood or approached. Ask for the context needed rather than forwarding the user's +message verbatim. + +Use relevant returned context in the work, but treat the current conversation and current +evidence as authoritative. Skip clearly self-contained requests for which stored context +is unlikely to matter. + +If returned context is stale, wrong, misleading, or overbroad, do not follow it; submit +the correction and current evidence with rightmemory_submit_update.""" + +SUBMIT_UPDATE_DESCRIPTION = """\ +Submit evidence for durable cross-session context or the current direction of meaningful +ongoing work when omitting it would likely cause poorer future decisions, substantial +rediscovery, or loss of continuity. + +Submit at a natural boundary once the evidence is clear; completion is not required. Do +not submit transient progress, routine results, unresolved discussion by itself, or +implementation detail already adequately preserved in project-local artifacts. Combine +related evidence due at the same boundary. + +State what happened, what is true now, and why it may matter. Do not prescribe stored +wording, identifiers, classification, placement, or edits. + +Processing is asynchronous. After an empty successful result, continue the task without +waiting, polling, or resubmitting. Only actionable failures or recovery warnings are +returned.""" + +CAPTURE_GUIDANCE_DESCRIPTION = """\ +Capture plausible evidence about how an agent should handle similar future work. Bias +toward capture rather than filtering: uncertainty about whether the pattern will recur is +not a reason to skip it, and similar captures from distinct occurrences are useful. + +Capture both direct guidance and explicit or implicit user redirections. A redirection is +a user response that changes or reveals how identifiable prior work should proceed. Infer +an implicit redirection from the contrast between the approach you were taking and the +direction the user now indicates. + +The signal may be a correction, rejection, unease, guiding question, added constraint or +information, or a change in conclusion, scope, reasoning, process, omissions, behavior, +or presentation. It does not need to be phrased as a general rule. + +Do not require a fully settled general principle or task completion. Capture once the +signal is concrete enough to describe the prior direction and what should change. + +Skip only mere continuation, selection among intentionally open options, an unrelated new +task, or a detail clearly confined to the current artifact with no plausible +agent-behavior lesson. Do not skip merely because the guidance may be one-off. + +Capture each distinct occurrence once. Similar guidance may be captured again when a +later interaction independently provides the same pattern. + +For a redirection, record the prior approach or omission, the user's signal, and the +resulting direction. For direct guidance, include enough context to judge its scope. Record +the interaction evidence; do not invent a broader rule, final stored wording, or +destination. Apply the resulting direction to the current work regardless of capture. + +When the user explicitly asks RightMemory to remember or follow guidance in future, use +rightmemory_submit_update instead. The same interaction may use both tools when it +provides distinct durable context and agent-behavior evidence. + +After an empty successful result, continue without waiting or polling.""" + +SessionId = Annotated[str, Field(description=SESSION_ID_DESCRIPTION, min_length=1)] +RetrieveNeed = Annotated[str, Field(description=RETRIEVE_NEED_DESCRIPTION, min_length=1)] +UpdateEvidence = Annotated[str, Field(description=UPDATE_EVIDENCE_DESCRIPTION, min_length=1)] +GuidanceEvidence = Annotated[str, Field(description=GUIDANCE_EVIDENCE_DESCRIPTION, min_length=1)] + +_MAX_ERROR_DETAIL_CHARS = 400 + + +class McpBackend(Protocol): + def retrieve(self, session_id: str, need: str) -> str: ... + + def submit_update(self, session_id: str, evidence: str) -> str | None: ... + + def capture_guidance(self, session_id: str, evidence: str) -> None: ... + + def actionable_warning(self) -> str | None: ... + + +@dataclass(frozen=True) +class DefaultMcpBackend: + memory_root: Path + + def retrieve(self, session_id: str, need: str) -> str: + runtime = RightMemoryRuntime(load_config("retrieve", memory_root=self.memory_root)) + try: + return runtime.run_session_turn(session_id, need) + finally: + runtime.cleanup() + + def submit_update(self, session_id: str, evidence: str) -> str | None: + store = AsyncUpdateStore(self.memory_root, "update") + candidate_uid = uuid.uuid4().hex + try: + store.submit( + session_id, + evidence, + candidate_uid=candidate_uid, + ) + except Exception as exc: + if not self._candidate_was_saved(store, session_id, candidate_uid): + raise + return ( + "RightMemory saved the update evidence, but could not start or wake its " + f"update worker: {_error_detail(exc)}. Tell the user to run " + "`rightmemory status`; do not resubmit the evidence." + ) + return None + + def _candidate_was_saved( + self, + store: AsyncUpdateStore, + session_id: str, + candidate_uid: str, + ) -> bool: + try: + state = store.read(session_id) + except Exception: + state = None + if state is not None and candidate_uid in state.accepted_candidate_uids: + return True + try: + return UpdateQueueStore(self.memory_root).read_outbox(candidate_uid) is not None + except Exception: + return False + + def capture_guidance(self, session_id: str, evidence: str) -> None: + submit_guidance(self.memory_root, session_id, evidence) + + def actionable_warning(self) -> str | None: + return _actionable_update_warning(self.memory_root) + + +def create_mcp_server( + memory_root: Path, + *, + backend: McpBackend | None = None, +) -> FastMCP: + selected_backend = backend or DefaultMcpBackend( + Path(memory_root).expanduser().resolve() + ) + server = FastMCP(name="RightMemory", log_level="WARNING") + + @server.tool( + name="rightmemory_retrieve", + description=RETRIEVE_DESCRIPTION, + structured_output=False, + ) + def rightmemory_retrieve( + session_id: SessionId, + need: RetrieveNeed, + ) -> CallToolResult: + clean_session = _clean_session_id(session_id) + clean_need = _clean_text(need, "retrieval need") + output = selected_backend.retrieve(clean_session, clean_need) + return _result(output, selected_backend.actionable_warning()) + + @server.tool( + name="rightmemory_submit_update", + description=SUBMIT_UPDATE_DESCRIPTION, + structured_output=False, + ) + def rightmemory_submit_update( + session_id: SessionId, + evidence: UpdateEvidence, + ) -> CallToolResult: + clean_session = _clean_session_id(session_id) + clean_evidence = _clean_text(evidence, "update evidence") + warning = selected_backend.submit_update(clean_session, clean_evidence) + if warning is None: + warning = selected_backend.actionable_warning() + return _result(warning) + + @server.tool( + name="rightmemory_capture_guidance", + description=CAPTURE_GUIDANCE_DESCRIPTION, + structured_output=False, + ) + def rightmemory_capture_guidance( + session_id: SessionId, + evidence: GuidanceEvidence, + ) -> CallToolResult: + clean_session = _clean_session_id(session_id) + clean_evidence = _clean_text(evidence, "guidance evidence") + selected_backend.capture_guidance(clean_session, clean_evidence) + return _result(selected_backend.actionable_warning()) + + return server + + +def mcp_main(memory_root: Path, argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="rightmemory mcp", + description="Serve RightMemory ordinary-agent tools over local MCP stdio.", + ) + parser.parse_args([] if argv is None else argv) + create_mcp_server(memory_root).run(transport="stdio") + return 0 + + +def _actionable_update_warning(memory_root: Path) -> str | None: + try: + return collect_update_recovery_summary(Path(memory_root)).warning() + except Exception as exc: + return ( + "RightMemory could not inspect update recovery state: " + f"{_error_detail(exc)}. Tell the user to run `rightmemory status`." + ) + + +def _result(*texts: str | None) -> CallToolResult: + return CallToolResult( + content=[ + TextContent(type="text", text=text) + for text in texts + if isinstance(text, str) and text.strip() + ] + ) + + +def _clean_session_id(value: str) -> str: + clean = value.strip() + if not clean or any(character in clean for character in "\x00\r\n"): + raise ValueError("session id must be a non-empty single line") + return clean + + +def _clean_text(value: str, label: str) -> str: + clean = value.strip() + if not clean: + raise ValueError(f"{label} must not be empty") + return clean + + +def _error_detail(exc: Exception) -> str: + raw = str(exc).strip() + detail = raw.splitlines()[0] if raw else type(exc).__name__ + if len(detail) > _MAX_ERROR_DETAIL_CHARS: + detail = detail[:_MAX_ERROR_DETAIL_CHARS] + "...[truncated]" + return f"{type(exc).__name__}: {detail}" diff --git a/rightmemory/update_alerts.py b/rightmemory/update_alerts.py new file mode 100644 index 0000000..39e4b57 --- /dev/null +++ b/rightmemory/update_alerts.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from .async_update import ( + STATUS_MANUAL_RECOVERY, + _is_legacy_failed_pending_state, + _state_from_json, +) +from .update_queue import UpdateQueueStore + + +@dataclass(frozen=True) +class UpdateRecoverySummary: + local_candidates: int = 0 + local_sessions: int = 0 + synchronized_candidates: int = 0 + + @property + def required(self) -> bool: + return bool(self.local_candidates or self.synchronized_candidates) + + def warning(self) -> str | None: + if not self.required: + return None + + scopes: list[str] = [] + if self.local_candidates: + scopes.append( + f"{self.local_candidates} local " + f"{_plural('candidate', self.local_candidates)} across " + f"{self.local_sessions} {_plural('session', self.local_sessions)}" + ) + if self.synchronized_candidates: + scopes.append( + f"{self.synchronized_candidates} synchronized " + f"{_plural('candidate', self.synchronized_candidates)}" + ) + return ( + f"RightMemory has {' and '.join(scopes)} requiring manual recovery. " + "Tell the user to run `rightmemory update retry`; do not resubmit queued evidence." + ) + + +def collect_update_recovery_summary(memory_root: Path) -> UpdateRecoverySummary: + root = Path(memory_root) + local_candidates, local_sessions = _local_recovery_counts(root) + synchronized_candidates = sum( + len(recovery.candidate_uids) + for recovery in UpdateQueueStore(root).snapshot().recoveries + if recovery.manual_recovery + ) + return UpdateRecoverySummary( + local_candidates=local_candidates, + local_sessions=local_sessions, + synchronized_candidates=synchronized_candidates, + ) + + +def _local_recovery_counts(memory_root: Path) -> tuple[int, int]: + state_root = memory_root / ".runtime" / "async" / "update" + if not state_root.exists() and not state_root.is_symlink(): + return 0, 0 + if state_root.is_symlink() or not state_root.is_dir(): + raise ValueError("async update state root must be a directory") + + candidates = 0 + sessions = 0 + for path in sorted(state_root.glob("*.json")): + if path.is_symlink() or not path.is_file(): + raise ValueError(f"async update state must be a regular file: {path.name}") + try: + state = _state_from_json(json.loads(path.read_text(encoding="utf-8"))) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid async update state JSON: {path.name}") from exc + if state.role != "update": + raise ValueError( + f"async update state role mismatch in {path.name}: " + f"expected update, got {state.role}" + ) + manual_recovery = ( + state.status == STATUS_MANUAL_RECOVERY + or _is_legacy_failed_pending_state(state) + ) + count = len(state.current_batch) + len(state.pending) + if manual_recovery and count: + candidates += count + sessions += 1 + return candidates, sessions + + +def _plural(noun: str, count: int) -> str: + return noun if count == 1 else noun + "s" diff --git a/skills/rightmemory-auto-orchestrator-cli/SKILL.md b/skills/rightmemory-auto-orchestrator-cli/SKILL.md index 2b67803..17f899c 100644 --- a/skills/rightmemory-auto-orchestrator-cli/SKILL.md +++ b/skills/rightmemory-auto-orchestrator-cli/SKILL.md @@ -39,18 +39,22 @@ Choose one stable session id for the conversation and reuse it for every RightMe ## Capture Agent Guidance -Capture guidance about how an agent should handle similar future work, including guidance revealed by a user redirection. +Capture plausible evidence about how an agent should handle similar future work. Bias toward capture rather than filtering: uncertainty about whether the pattern will recur is not a reason to skip it, and similar captures from distinct occurrences are useful. -A user redirection occurs when the user's response, explicitly or implicitly, materially changes the course of identifiable prior work. Judge it by the settled contrast between what you were on course to produce or do and the resulting direction; the difference may concern the conclusion, scope, reasoning, process, omissions, behavior, or presentation. +Capture both direct guidance and explicit or implicit user redirections. A redirection is a user response that changes or reveals how identifiable prior work should proceed. Infer an implicit redirection from the contrast between the approach you were taking and the direction the user now indicates. -Unease, a guiding question, or added information may qualify. Mere continuation, selection among intentionally open options, or a new task does not. +The signal may be a correction, rejection, unease, guiding question, added constraint or information, or a change in conclusion, scope, reasoning, process, omissions, behavior, or presentation. It does not need to be phrased as a general rule. -Capture it when the resulting direction is clear and it may be useful in similar future work. Do not capture unresolved discussion or an obviously one-off local adjustment. Task completion is not required. +Do not require a fully settled general principle or task completion. Capture once the signal is concrete enough to describe the prior direction and what should change. -If the user explicitly asks RightMemory to remember the guidance or follow it in future, submit it through Update. Otherwise use: +Skip only mere continuation, selection among intentionally open options, an unrelated new task, or a detail clearly confined to the current artifact with no plausible agent-behavior lesson. Do not skip merely because the guidance may be one-off. + +Capture each distinct occurrence once. Similar guidance may be captured again when a later interaction independently provides the same pattern. + +If the user explicitly asks RightMemory to remember or follow guidance in future, submit it through Update. Otherwise use: `rightmemory guidance submit --session ""` -For a redirection, include the prior attempt or omission, the user redirection, and the resulting direction. For direct guidance, include the guidance and enough context to judge its scope. Do not prescribe final stored wording or destination. +For a redirection, record the prior approach or omission, the user's signal, and the resulting direction. For direct guidance, include enough context to judge its scope. Record the interaction evidence; do not invent a broader rule, final stored wording, or destination. Apply the resulting direction to the current work regardless of capture. One interaction may produce both an Update candidate and a guidance candidate when they preserve distinct evidence. Continue the user's task without waiting. diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..200e484 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import json +import tempfile +import unittest +from dataclasses import asdict +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from mcp.types import CallToolResult + +from rightmemory import entrypoint +from rightmemory.async_update import ( + STATUS_MANUAL_RECOVERY, + AsyncUpdateJob, + AsyncUpdateState, +) +from rightmemory.mcp import DefaultMcpBackend, create_mcp_server +from rightmemory.update_alerts import collect_update_recovery_summary +from rightmemory.update_queue import UpdateQueueRecovery, UpdateQueueStore + + +class FakeBackend: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str]] = [] + self.warning: str | None = None + self.submit_warning: str | None = None + + def retrieve(self, session_id: str, need: str) -> str: + self.calls.append(("retrieve", session_id, need)) + return "retrieved context" + + def submit_update(self, session_id: str, evidence: str) -> str | None: + self.calls.append(("submit", session_id, evidence)) + return self.submit_warning + + def capture_guidance(self, session_id: str, evidence: str) -> None: + self.calls.append(("guidance", session_id, evidence)) + + def actionable_warning(self) -> str | None: + return self.warning + + +def call_tool(server, name: str, arguments: dict[str, str]) -> CallToolResult: + result = asyncio.run(server.call_tool(name, arguments)) + if not isinstance(result, CallToolResult): + raise AssertionError(f"expected CallToolResult, got {type(result).__name__}") + return result + + +class McpToolTests(unittest.TestCase): + def setUp(self) -> None: + self.backend = FakeBackend() + self.server = create_mcp_server(Path("/unused"), backend=self.backend) + + def test_server_exposes_only_the_three_ordinary_agent_tools(self): + tools = asyncio.run(self.server.list_tools()) + self.assertEqual( + {tool.name for tool in tools}, + { + "rightmemory_retrieve", + "rightmemory_submit_update", + "rightmemory_capture_guidance", + }, + ) + + def test_retrieve_trims_arguments_and_returns_context_plus_actionable_warning(self): + self.backend.warning = "warning" + result = call_tool( + self.server, + "rightmemory_retrieve", + {"session_id": " session ", "need": " need "}, + ) + + self.assertEqual(self.backend.calls, [("retrieve", "session", "need")]) + self.assertEqual(len(result.content), 2) + + def test_successful_update_submission_is_silent(self): + result = call_tool( + self.server, + "rightmemory_submit_update", + {"session_id": "session", "evidence": "evidence"}, + ) + + self.assertEqual(self.backend.calls, [("submit", "session", "evidence")]) + self.assertEqual(result.content, []) + + def test_update_submission_returns_only_an_actionable_warning(self): + self.backend.submit_warning = "warning" + result = call_tool( + self.server, + "rightmemory_submit_update", + {"session_id": "session", "evidence": "evidence"}, + ) + + self.assertEqual(len(result.content), 1) + + def test_successful_guidance_capture_is_silent(self): + result = call_tool( + self.server, + "rightmemory_capture_guidance", + {"session_id": " session ", "evidence": " evidence "}, + ) + + self.assertEqual(self.backend.calls, [("guidance", "session", "evidence")]) + self.assertEqual(result.content, []) + + +class DefaultMcpBackendTests(unittest.TestCase): + def test_post_save_worker_failure_does_not_ask_for_resubmission(self): + candidate_uid = "a" * 32 + store = SimpleNamespace() + store.submit = unittest.mock.Mock(side_effect=RuntimeError("worker failed")) + store.read = unittest.mock.Mock( + return_value=SimpleNamespace(accepted_candidate_uids=[candidate_uid]) + ) + + with patch("rightmemory.mcp.AsyncUpdateStore", return_value=store), patch( + "rightmemory.mcp.uuid.uuid4", + return_value=SimpleNamespace(hex=candidate_uid), + ): + warning = DefaultMcpBackend(Path("/memory")).submit_update( + "session", + "evidence", + ) + + self.assertIsInstance(warning, str) + store.submit.assert_called_once_with( + "session", + "evidence", + candidate_uid=candidate_uid, + ) + + def test_failure_before_candidate_is_saved_remains_an_error(self): + candidate_uid = "b" * 32 + store = SimpleNamespace() + store.submit = unittest.mock.Mock(side_effect=RuntimeError("not saved")) + store.read = unittest.mock.Mock( + return_value=SimpleNamespace(accepted_candidate_uids=[]) + ) + queue = SimpleNamespace() + queue.read_outbox = unittest.mock.Mock(return_value=None) + + with patch("rightmemory.mcp.AsyncUpdateStore", return_value=store), patch( + "rightmemory.mcp.UpdateQueueStore", + return_value=queue, + ), patch( + "rightmemory.mcp.uuid.uuid4", + return_value=SimpleNamespace(hex=candidate_uid), + ): + with self.assertRaises(RuntimeError): + DefaultMcpBackend(Path("/memory")).submit_update( + "session", + "evidence", + ) + + +class UpdateRecoveryAlertTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.root = Path(self.tempdir.name) + + def test_clean_root_has_no_recovery_alert(self): + summary = collect_update_recovery_summary(self.root) + + self.assertFalse(summary.required) + self.assertEqual(summary.local_candidates, 0) + self.assertEqual(summary.synchronized_candidates, 0) + self.assertIsNone(summary.warning()) + + def test_counts_local_manual_recovery_without_mutating_state(self): + state_root = self.root / ".runtime" / "async" / "update" + state_root.mkdir(parents=True) + state = AsyncUpdateState( + status=STATUS_MANUAL_RECOVERY, + session_id="session-one", + role="update", + current_batch=[ + AsyncUpdateJob( + id=1, + candidate_uid="1" * 32, + message="one", + submitted_at="2026-08-18T00:00:00+00:00", + ) + ], + pending=[ + AsyncUpdateJob( + id=2, + candidate_uid="2" * 32, + message="two", + submitted_at="2026-08-18T00:01:00+00:00", + ) + ], + accepted_candidate_uids=["1" * 32, "2" * 32], + next_id=3, + ) + path = state_root / "session-one.json" + content = json.dumps(asdict(state), ensure_ascii=False, indent=2) + "\n" + path.write_text(content, encoding="utf-8") + + summary = collect_update_recovery_summary(self.root) + + self.assertEqual(summary.local_candidates, 2) + self.assertEqual(summary.local_sessions, 1) + self.assertTrue(summary.required) + self.assertEqual(path.read_text(encoding="utf-8"), content) + + def test_counts_synchronized_manual_recovery(self): + UpdateQueueStore(self.root).write_recovery( + UpdateQueueRecovery( + batch_id="update-batch-" + "3" * 64, + candidate_uids=("4" * 32, "5" * 32), + attempts=2, + reason_code="processing_failed", + retry_at=None, + manual_recovery=True, + ) + ) + + summary = collect_update_recovery_summary(self.root) + + self.assertEqual(summary.synchronized_candidates, 2) + self.assertTrue(summary.required) + + def test_malformed_local_state_is_reported(self): + state_root = self.root / ".runtime" / "async" / "update" + state_root.mkdir(parents=True) + (state_root / "broken.json").write_text("{", encoding="utf-8") + + with self.assertRaises(ValueError): + collect_update_recovery_summary(self.root) + + +class McpEntrypointTests(unittest.TestCase): + def test_entrypoint_resolves_root_and_starts_mcp(self): + root = Path("/resolved-memory") + from rightmemory import mcp as mcp_module + + with patch.object( + entrypoint, + "resolve_memory_root", + return_value=SimpleNamespace(memory_root=root), + ), patch.object(mcp_module, "mcp_main", return_value=0) as run: + result = entrypoint.main(["--profile", "project", "mcp"]) + + self.assertEqual(result, 0) + run.assert_called_once_with(root, []) + + +if __name__ == "__main__": + unittest.main() From 5dfa7a158c28d1925bbf5bcba8ab552a0a935b4a Mon Sep 17 00:00:00 2001 From: RightL Date: Tue, 18 Aug 2026 13:27:16 +0800 Subject: [PATCH 2/2] test: seed synchronized recovery candidates --- tests/test_mcp.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 200e484..0144f3d 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -19,7 +19,12 @@ ) from rightmemory.mcp import DefaultMcpBackend, create_mcp_server from rightmemory.update_alerts import collect_update_recovery_summary -from rightmemory.update_queue import UpdateQueueRecovery, UpdateQueueStore +from rightmemory.update_queue import ( + UpdateCandidate, + UpdateQueueRecovery, + UpdateQueueStore, + update_candidate_batch_id, +) class FakeBackend: @@ -209,10 +214,29 @@ def test_counts_local_manual_recovery_without_mutating_state(self): self.assertEqual(path.read_text(encoding="utf-8"), content) def test_counts_synchronized_manual_recovery(self): - UpdateQueueStore(self.root).write_recovery( + candidates = ( + UpdateCandidate( + uid="4" * 32, + session_id="session-four", + display_id=1, + message="four", + submitted_at="2026-08-18T00:00:00+00:00", + ), + UpdateCandidate( + uid="5" * 32, + session_id="session-five", + display_id=1, + message="five", + submitted_at="2026-08-18T00:01:00+00:00", + ), + ) + store = UpdateQueueStore(self.root) + for candidate in candidates: + store.write_candidate(candidate) + store.write_recovery( UpdateQueueRecovery( - batch_id="update-batch-" + "3" * 64, - candidate_uids=("4" * 32, "5" * 32), + batch_id=update_candidate_batch_id(candidates), + candidate_uids=tuple(candidate.uid for candidate in candidates), attempts=2, reason_code="processing_failed", retry_at=None,