Skip to content
Merged
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
78 changes: 66 additions & 12 deletions mcp_server/handlers/wiki_list.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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,
}
113 changes: 104 additions & 9 deletions mcp_server/handlers/wiki_read.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
"""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",
"annotations": READ_ONLY,
"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",
Expand All @@ -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),
}
39 changes: 36 additions & 3 deletions mcp_server/handlers/wiki_reindex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
"<!-- Generated by Cortex wiki_reindex — the authored pages are the "
Expand Down Expand Up @@ -68,13 +69,43 @@ def _render_index(grouped: dict[str, list[str]]) -> str:
return "\n".join(lines)


def _is_redirect_page(rel_path: str) -> bool:
"""True iff the page declares a redirect (read frontmatter from disk)."""
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]:
"""Phase 3.2 of ADR-2244: redirect stubs are excluded from the index.

The reader-facing INDEX.md should only list live pages — a stub at
an old path is a navigation aid for inbound links, not content to
advertise. The summary still reports the count of stubs so callers
have visibility.
"""
grouped: dict[str, list[str]] = {}
redirects_by_kind: dict[str, int] = {}
for kind in PAGE_KINDS:
try:
grouped[kind] = list_pages(WIKI_ROOT, kind=kind)
all_pages = list_pages(WIKI_ROOT, kind=kind)
except (ValueError, OSError):
grouped[kind] = []
redirects_by_kind[kind] = 0
continue
live: list[str] = []
n_redirect = 0
for rel in all_pages:
if _is_redirect_page(rel):
n_redirect += 1
else:
live.append(rel)
grouped[kind] = live
redirects_by_kind[kind] = n_redirect

content = _render_index(grouped)
target = Path(WIKI_ROOT) / str(index_path())
Expand All @@ -87,5 +118,7 @@ async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]:
"path": str(index_path()),
"total_pages": sum(len(v) for v in grouped.values()),
"by_kind": {k: len(v) for k, v in grouped.items()},
"redirects_by_kind": redirects_by_kind,
"redirect_count": sum(redirects_by_kind.values()),
"root": str(WIKI_ROOT),
}
Loading