From 1016e2d614665eb6a5b2c066aa054c8086401d22 Mon Sep 17 00:00:00 2001 From: cdeust Date: Wed, 13 May 2026 11:02:40 +0200 Subject: [PATCH] feat(wiki): handler-layer redirect mechanics + wiki_rename (ADR-2244 Phase 3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the Phase 3 data model (#33) into the read path and adds a new write handler that performs the rename + stub atomically. With this change ``wiki_rename old.md new.md`` produces: * ``new.md`` — the original content moved verbatim (id preserved) * ``old.md`` — a redirect stub pointing at new.md (with redirect_id = source page id, for future id-based resolution) And ``wiki_read old.md`` then returns the content of ``new.md`` along with ``redirect_chain: ["old.md", "new.md"]``. Inbound links to the old path keep working through the migration. Handler changes --------------- * ``wiki_read`` — follow redirect stubs transparently up to 5 hops. ``follow_redirects: false`` opts out (admin/migration tooling that needs to inspect the stub itself). New response field: ``redirect_chain``. * ``wiki_list`` — exclude redirect stubs from the listing by default. ``include_redirects: true`` opts in. New response field: ``redirect_count``. * ``wiki_reindex`` — drop redirect stubs from .generated/INDEX.md and surface the count by kind in the response. The index now lists only live pages, which is what readers actually want. * ``wiki_rename`` — NEW. Move a page from one path to another and leave a stub at the old path. Refuses to operate on pages without a stable frontmatter id (run ``scripts/wiki_backfill_ids.py --apply`` first), refuses to chain stubs (rename the terminal page instead), refuses to overwrite an existing destination unless ``overwrite_dest=true``. Tool registry: ``wiki_rename`` registered alongside the other 8 wiki tools. ``wiki_read`` and ``wiki_list`` MCP signatures extended with their new optional parameters. Stub semantics -------------- The stub carries ``redirect_id = `` so future id-based resolution (which a follow-up will add for cross-rename resolution when the path itself is renamed twice) works. ``redirect_to`` is populated with the new path as the cheap path-based resolution target. Both forms are emitted; the id wins when an id-aware reader arrives. Tests ----- ``tests_py/handlers/test_wiki_redirect_handlers.py`` (NEW) — 20 tests covering every handler change: read: - returns content for a normal page (chain = []) - follows single-hop redirect - follows multi-hop chain (3 pages, 2 hops) - ``follow_redirects: false`` returns the stub itself - cycle returns error - dangling redirect returns error - missing source returns error list: - excludes stubs by default; redirect_count surfaced - ``include_redirects: true`` returns both - redirect_count is 0 when no stubs reindex: - stubs absent from INDEX.md; by_kind counts only live pages rename: - creates stub at old path with correct redirect_to, redirect_id, redirect_reason - refuses missing source - refuses source without id - refuses existing destination - ``overwrite_dest=true`` works - refuses to chain stubs - refuses same path - end-to-end: rename then read resolves to the new content - body preserved verbatim through the move Targeted suite: 86 passed (Phase 3 + Phase 3.2 surface). Broader: tests_py/core/ + tests_py/shared/ + tests_py/scripts/ + relevant tests_py/handlers/ → 2075 passed. ``ruff format --check`` and ``ruff check`` clean. What still ships in a follow-up ------------------------------- * ID→path index for ID-only redirect resolution (currently only path-based chain walking works; id-only stubs return None from resolve_chain so they error in wiki_read with a clear message). * Phase 4 bulk migration script that loops wiki_rename over the 88 known pollution paths (.md.md slug bug, timestamp-slugs, path-leak titles) — gated on this PR + #33 landing. Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp_server/handlers/wiki_list.py | 78 ++++- mcp_server/handlers/wiki_read.py | 113 +++++- mcp_server/handlers/wiki_reindex.py | 39 ++- mcp_server/handlers/wiki_rename.py | 175 ++++++++++ mcp_server/tool_registry_wiki.py | 57 +++- .../handlers/test_wiki_redirect_handlers.py | 321 ++++++++++++++++++ 6 files changed, 752 insertions(+), 31 deletions(-) create mode 100644 mcp_server/handlers/wiki_rename.py create mode 100644 tests_py/handlers/test_wiki_redirect_handlers.py diff --git a/mcp_server/handlers/wiki_list.py b/mcp_server/handlers/wiki_list.py index 5839f254..9141ac6c 100644 --- a/mcp_server/handlers/wiki_list.py +++ b/mcp_server/handlers/wiki_list.py @@ -1,13 +1,19 @@ -"""Handler: wiki_list — enumerate authored wiki pages.""" +"""Handler: wiki_list — enumerate authored wiki pages. + +Phase 3.2 of ADR-2244: redirect stubs are filtered from the listing by +default. Pass ``include_redirects: true`` to see them — useful for +migration tooling that needs to audit or clean up old paths. +""" from __future__ import annotations from typing import Any from mcp_server.core.wiki_layout import PAGE_KINDS -from mcp_server.infrastructure.config import WIKI_ROOT -from mcp_server.infrastructure.wiki_store import list_pages +from mcp_server.core.wiki_redirect import is_redirect, parse_frontmatter from mcp_server.handlers._tool_meta import READ_ONLY +from mcp_server.infrastructure.config import WIKI_ROOT +from mcp_server.infrastructure.wiki_store import list_pages, read_page schema = { "title": "Wiki — list pages", @@ -16,13 +22,13 @@ "Enumerate every authored wiki page under ~/.claude/methodology/wiki/, " "filesystem-walked from the wiki root. Optionally restrict by kind " "(adr, specs, guides, reference, conventions, lessons, notes, " - "journal, files). Use this to browse what already exists before " - "writing a new page, to feed a downstream selector, or to build a " - "manual cross-reference. Read-only; never modifies anything. Distinct " - "from `wiki_reindex` which generates the .generated/INDEX.md from the " - "same enumeration, and from `wiki_read` which fetches one page's " - "content. Latency <50ms even for thousands of pages. Returns " - "{root, count, pages: list[wiki-relative path]}." + "journal, files). Redirect stubs (frontmatter ``redirect_to:`` or " + "``redirect_id:``) are excluded by default; pass " + "``include_redirects: true`` to see them. Read-only; never modifies " + "anything. Distinct from `wiki_reindex` which generates the " + ".generated/INDEX.md from the same enumeration, and from `wiki_read` " + "which fetches one page's content. Latency <50ms even for thousands " + "of pages. Returns {root, count, pages, redirect_count}." ), "inputSchema": { "type": "object", @@ -37,16 +43,64 @@ "enum": list(PAGE_KINDS), "examples": ["adr", "lessons", "notes"], }, + "include_redirects": { + "type": "boolean", + "description": ( + "When true, redirect stubs are included in the listing. " + "Default false — most callers want the live pages only." + ), + "default": False, + }, }, }, } +def _is_redirect_page(rel_path: str) -> bool: + """True iff the page at ``rel_path`` declares a redirect in its frontmatter. + + Reads the file via the sandboxed store and parses only the + frontmatter block — cheap on a 9000-page wiki because the + frontmatter is at the top of the file and we don't need to load + the body. + """ + try: + content = read_page(WIKI_ROOT, rel_path) + except (ValueError, OSError): + return False + if content is None: + return False + return is_redirect(parse_frontmatter(content)) + + async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]: args = args or {} kind = args.get("kind") + include_redirects = bool(args.get("include_redirects", False)) + try: - pages = list_pages(WIKI_ROOT, kind=kind if kind else None) + all_pages = list_pages(WIKI_ROOT, kind=kind if kind else None) except (ValueError, OSError) as exc: return {"error": f"list failed: {exc}"} - return {"root": str(WIKI_ROOT), "count": len(pages), "pages": pages} + + if include_redirects: + return { + "root": str(WIKI_ROOT), + "count": len(all_pages), + "pages": all_pages, + "redirect_count": 0, # not partitioned in this mode + } + + live_pages: list[str] = [] + redirect_count = 0 + for rel in all_pages: + if _is_redirect_page(rel): + redirect_count += 1 + else: + live_pages.append(rel) + return { + "root": str(WIKI_ROOT), + "count": len(live_pages), + "pages": live_pages, + "redirect_count": redirect_count, + } diff --git a/mcp_server/handlers/wiki_read.py b/mcp_server/handlers/wiki_read.py index c77310d7..640b68f2 100644 --- a/mcp_server/handlers/wiki_read.py +++ b/mcp_server/handlers/wiki_read.py @@ -1,12 +1,29 @@ -"""Handler: wiki_read — fetch the raw markdown of a wiki page.""" +"""Handler: wiki_read — fetch the raw markdown of a wiki page. + +Phase 3.2 of ADR-2244: when the page at the requested path is a redirect +stub, follow the chain transparently and return the target's content. +The response carries a ``redirect_chain`` array recording the paths +walked, so callers can detect and surface a "this page moved" hint to +their users when the caller cares. + +Caller can opt out of redirect-following via ``follow_redirects: false`` +to read the stub itself (useful for admin / migration tooling that +needs to inspect or rewrite the stub). +""" from __future__ import annotations from typing import Any +from mcp_server.core.wiki_redirect import ( + MAX_REDIRECT_DEPTH, + parse_frontmatter, + parse_redirect, + resolve_chain, +) +from mcp_server.handlers._tool_meta import READ_ONLY from mcp_server.infrastructure.config import WIKI_ROOT from mcp_server.infrastructure.wiki_store import read_page -from mcp_server.handlers._tool_meta import READ_ONLY schema = { "title": "Wiki — read page", @@ -14,12 +31,15 @@ "description": ( "Fetch the raw markdown source of one wiki page by its wiki-relative " "path. Path resolution is sandboxed under the wiki root — absolute " - "paths and `../` traversal are rejected at the storage layer. Read-" - "only; never mutates state. Use this to quote, link, or edit-prep an " - "ADR/spec/lesson page before further action. Distinct from `wiki_list` " - "which enumerates available pages, and from `wiki_export` which " - "renders a page through Pandoc to PDF/DOCX/HTML. Latency <10ms. " - "Returns {path, content (markdown verbatim), root} or {error}." + "paths and `../` traversal are rejected at the storage layer. When " + "the page is a redirect stub (frontmatter ``redirect_to:`` or " + "``redirect_id:``) the chain is followed transparently up to " + f"{MAX_REDIRECT_DEPTH} hops; cycles and dangling targets surface as " + "errors. Pass ``follow_redirects: false`` to read the stub itself. " + "Read-only; never mutates state. Distinct from `wiki_list` which " + "enumerates available pages, and from `wiki_export` which renders a " + "page through Pandoc to PDF/DOCX/HTML. Latency <10ms. Returns " + "{path, content, root, redirect_chain} or {error}." ), "inputSchema": { "type": "object", @@ -38,20 +58,95 @@ "specs/cortex/recall-pipeline.md", ], }, + "follow_redirects": { + "type": "boolean", + "description": ( + "When true (default) redirect stubs are followed " + "transparently. When false the stub itself is returned, " + "which is useful for admin / migration tooling." + ), + "default": True, + }, }, }, } +def _frontmatter_reader(rel_path: str) -> dict[str, object]: + """Read a page's frontmatter from disk via the sandboxed store. + + Returns an empty dict for missing or unreadable pages — that matches + the ``parse_redirect`` contract (no redirect = no decoration). + """ + try: + content = read_page(WIKI_ROOT, rel_path) + except (ValueError, OSError): + return {} + if content is None: + return {} + return parse_frontmatter(content) + + async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]: args = args or {} rel_path = str(args.get("path") or "").strip() if not rel_path: return {"error": "path is required"} + follow = bool(args.get("follow_redirects", True)) + try: content = read_page(WIKI_ROOT, rel_path) except (ValueError, OSError) as exc: return {"error": f"read failed: {exc}"} if content is None: return {"error": f"page not found: {rel_path}"} - return {"path": rel_path, "content": content, "root": str(WIKI_ROOT)} + + # Fast path: caller wants the stub itself, or the page isn't a stub. + if not follow: + return { + "path": rel_path, + "content": content, + "root": str(WIKI_ROOT), + "redirect_chain": [], + } + + fm = parse_frontmatter(content) + if parse_redirect(fm) is None: + return { + "path": rel_path, + "content": content, + "root": str(WIKI_ROOT), + "redirect_chain": [], + } + + # Stub — walk the chain to the terminal page. + resolved = resolve_chain(rel_path, _frontmatter_reader) + if resolved is None: + return { + "error": ( + f"redirect chain from {rel_path} could not be resolved " + f"(cycle, depth > {MAX_REDIRECT_DEPTH}, or id-only redirect " + f"without a path)" + ), + "path": rel_path, + } + + try: + target_content = read_page(WIKI_ROOT, resolved.final_path) + except (ValueError, OSError) as exc: + return {"error": f"redirect target read failed: {exc}"} + if target_content is None: + return { + "error": ( + f"redirect chain from {rel_path} terminates at " + f"{resolved.final_path}, but that page no longer exists" + ), + "redirect_chain": list(resolved.chain), + } + + return { + "path": resolved.final_path, + "content": target_content, + "root": str(WIKI_ROOT), + "redirect_chain": list(resolved.chain), + } diff --git a/mcp_server/handlers/wiki_reindex.py b/mcp_server/handlers/wiki_reindex.py index 3f04bec2..4df0e6c7 100644 --- a/mcp_server/handlers/wiki_reindex.py +++ b/mcp_server/handlers/wiki_reindex.py @@ -12,10 +12,11 @@ from typing import Any from mcp_server.core.wiki_layout import PAGE_KINDS, index_path +from mcp_server.core.wiki_redirect import is_redirect, parse_frontmatter +from mcp_server.handlers._tool_meta import IDEMPOTENT_WRITE from mcp_server.infrastructure.config import WIKI_ROOT from mcp_server.infrastructure.file_io import ensure_dir -from mcp_server.infrastructure.wiki_store import list_pages -from mcp_server.handlers._tool_meta import IDEMPOTENT_WRITE +from mcp_server.infrastructure.wiki_store import list_pages, read_page _BANNER = ( "