"""Generate the API reference pages and navigation. Zensical does not run MkDocs plugins, so the work that `mkdocs-gen-files` and `mkdocs-literate-nav` used to do at build time happens here as a plain pre-build step: this module writes a mkdocstrings stub (`::: `) for every public module under `docs/api/` and returns the matching nested navigation, which `scripts/docs/build_config.py` splices into the build config. Run as a script it just (re)generates `docs/api/`; imported, `generate` also returns the nav so the config builder can consume it. """ from __future__ import annotations import shutil from pathlib import Path import griffe # A MkDocs/Zensical nav is a list of entries, each either `{title: url}` for a # page or `{title: [children]}` for a section (a bare `url` string attaches # a section index page, courtesy of the `navigation.indexes` feature). NavItem = "str | dict[str, str | list[NavItem]]" ROOT = Path(__file__).parent.parent.parent API_DIR = ROOT / "docs" / "api" # `src/mcp-types` is a distribution directory, not an import package, so each # package's dotted module path is taken relative to its own parent: deriving # it from `src/` would emit the unimportable `mcp-types.mcp_types.*`. PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types") _KIND_SECTIONS = { griffe.Kind.MODULE: "Modules", griffe.Kind.CLASS: "Classes", griffe.Kind.FUNCTION: "Functions", griffe.Kind.ATTRIBUTE: "Attributes", griffe.Kind.TYPE_ALIAS: "Type aliases", } class _Node: """A module (`url`) and/or a package with child modules (`children`).""" def __init__(self) -> None: self.url: str | None = None self.children: dict[str, _Node] = {} def child(self, name: str) -> _Node: return self.children.setdefault(name, _Node()) def to_nav(self, title: str) -> NavItem: if not self.children: assert self.url is not None return {title: self.url} items: list[NavItem] = [] if self.url is not None: items.append(self.url) items.extend(self.children[name].to_nav(name) for name in sorted(self.children)) return {title: items} def _compact_index(module: griffe.Module, documented: set[str]) -> str | None: """Build a compact page body for a module that re-exports from outside its own subtree. mkdocstrings renders a re-export whose canonical documentation lives on another page as a full duplicate of it: deterministically for aliases within one top-level package (`mcp.client.auth` re-exporting from `mcp.shared.auth`), and order-dependently across top-level packages (`from mcp_types import y` + `__all__` renders the duplicate only when the other package happens to be loaded already, and silently omits the member when it isn't). Modules whose exports all live in their own subtree (`mcp_types` re-exporting its private `._types` module, or a module whose `__all__` lists only its own definitions) are unaffected and keep the plain `::: module` stub (return `None`): their page is itself the canonical rendering. For an affected module, pin the semantics instead of inheriting the accident: every export whose canonical page exists elsewhere under the API reference becomes a link to it, and only exports documented nowhere else (re-exports from private modules) keep their full body here, via an explicit `members:` list. """ prefix = f"{module.path}." exports: dict[str, griffe.Object | griffe.Alias] = {} for export in module.exports or (): name = str(export) # Listed exports must be statically documentable: a name provided at # runtime (module `__getattr__`) is a docs error by policy, not a skip. if (member := module.members.get(name)) is None: msg = f"gen_ref_pages: export {module.path}.{name} is not statically visible" raise SystemExit(msg) exports[name] = member if not any(member.is_alias and not member.target_path.startswith(prefix) for member in exports.values()): return None # A plain stub also renders the module's own public members that are not # in `__all__` (`show_if_no_docstring: false` hides the docstring-less # ones); keep them, so flipping a page to compact drops nothing. public = dict(exports) for name, member in module.members.items(): if ( name not in public and not name.startswith("_") and not member.is_alias and member.kind is not griffe.Kind.MODULE and member.has_docstrings ): public[name] = member inline: list[str] = [] sections: dict[str, list[str]] = {} for name in sorted(public, key=str.lower): member = public[name] try: target = member.final_target if member.is_alias else member except griffe.AliasResolutionError as exc: msg = f"gen_ref_pages: export {module.path}.{name} resolves outside the documented packages" raise SystemExit(msg) from exc # Link to the anchor another page actually renders: the deepest alias # hop whose module is documented (the final target may live in a # private module and only be re-exported by a public one), rendered # there only when docstringed (`show_if_no_docstring: false`). anchor = None hop = member while hop.is_alias: if hop.target_path.rpartition(".")[0] in documented: anchor = hop.target_path hop = hop.target if anchor is not None and member.has_docstrings: link_target = anchor else: inline.append(name) link_target = f"{module.path}.{name}" entry = f"- [`{name}`][{link_target}]" if docstring := target.docstring: summary = " ".join(docstring.value.split("\n\n", 1)[0].split("\n")) entry += f" — {summary}" sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry) # Rendering the stub resolves the cross-package aliases again, in # mkdocstrings' own collection. On a warm incremental rebuild the target # package's pages can all be cache hits, so nothing else loads it and the # resolution crashes (AliasResolutionError); preloading pins it. The # module's own root package needs no pin: rendering the stub loads it. preload = sorted( {member.target_path.split(".")[0] for member in exports.values() if member.is_alias} - {module.path.split(".")[0]} ) body = [f"::: {module.path}", " options:"] if preload: body += [" preload_modules:", *(f" - {pkg}" for pkg in preload)] if inline: body += [" members:", *(f" - {name}" for name in inline)] else: body += [" members: false"] body.append("") for title in _KIND_SECTIONS.values(): if title in sections: body += [f"## {title}", "", *sections[title], ""] return "\n".join(body) def _stub(title: str, body: str) -> str: """A stub page: explicit title frontmatter plus the page body. The explicit title matters: the stubs have no H1 of their own, and a title-less page falls back to "Index"/the filename — which is what pruned nav rows, browser tabs, and search results show. """ return f'---\ntitle: "{title}"\n---\n\n{body.rstrip()}\n' def generate() -> list[NavItem]: """Write `docs/api/**.md` stubs and return the API-section navigation.""" if API_DIR.exists(): shutil.rmtree(API_DIR) root = _Node() stubs: dict[Path, str] = {} pages: dict[str, Path] = {} documented: set[str] = set() for package in PACKAGES: base = package.parent for path in sorted(package.rglob("*.py")): module_path = path.relative_to(base).with_suffix("") doc_path = path.relative_to(base).with_suffix(".md") parts = tuple(module_path.parts) if parts[-1] == "__init__": parts = parts[:-1] doc_path = doc_path.with_name("index.md") # A private component anywhere makes the module private: checking # only the leaf would publish pages for e.g. mcp._vendor.util. if any(part.startswith("_") for part in parts): continue ident = ".".join(parts) documented.add(ident) stubs[API_DIR / doc_path] = _stub(parts[-1], f"::: {ident}") pages[ident] = API_DIR / doc_path node = root for part in parts: node = node.child(part) node.url = f"api/{doc_path.as_posix()}" # Load the root packages before inspecting any module: aliases only # resolve once the module they point at is in the loader's collection. loader = griffe.GriffeLoader(search_paths=[str(package.parent) for package in PACKAGES]) for package in PACKAGES: loader.load(package.name) for ident, doc_path in pages.items(): try: module = loader.modules_collection[ident] except KeyError as exc: raise SystemExit(f"gen_ref_pages: cannot find {ident} in the loaded packages") from exc if not isinstance(module, griffe.Module): raise SystemExit(f"gen_ref_pages: {ident} is shadowed by a non-module member") if body := _compact_index(module, documented): stubs[doc_path] = _stub(ident.rpartition(".")[2], body) for full_doc_path, stub in stubs.items(): full_doc_path.parent.mkdir(parents=True, exist_ok=True) full_doc_path.write_text(stub, encoding="utf-8") return [root.children[name].to_nav(name) for name in sorted(root.children)] if __name__ == "__main__": generate()