#!/usr/bin/env python3 """Regenerate the Tools/Resources catalogue sections of docs/reference/mcp.md. musehub#209: the hand-maintained doc drifted badly — only 38 of 127 live tools were documented at all (not just stale counts), plus several resources that no longer exist were still listed while newer ones were missing entirely. Counting and transcribing ~150 tool/resource entries by hand doesn't scale and re-drifts the moment anyone adds a tool without remembering to also update this file. This script introspects the actual runtime registries — MCP_TOOLS, MUSEHUB_WRITE_TOOL_NAMES, MUSEHUB_ELICITATION_TOOL_NAMES, STATIC_RESOURCES, RESOURCE_TEMPLATES — the exact same data the live server returns from ``tools/list``/``resources/list``/``resources/templates/list`` — and regenerates the per-item catalogue sections plus their ToC/heading counts. Scope, deliberately: only the Tools and Resources catalogue sections are generated. The Prompts section is NOT touched — at the time this script was written every live prompt was already individually documented with rich, hand-curated multi-step workflow guidance that the prompt registry's plain description field can't reproduce; regenerating it would be a content downgrade for zero coverage gain. All other sections (Architecture, Transports, Authentication, Session Management, Elicitation, Error Handling, Usage Patterns, Architecture Diagrams) are pure hand-written prose and are never touched. Regenerated regions are delimited by HTML comment markers already present in the doc: ... Usage:: # Regenerate docs/reference/mcp.md in place: python scripts/gen_mcp_reference.py # Check that the committed doc matches current source (CI gate): python scripts/gen_mcp_reference.py --check Exit codes: 0 — OK (or --check passed with no drift). 1 — --check detected drift; see stderr for a diff summary. """ from __future__ import annotations import argparse import difflib import re import sys from pathlib import Path from typing import TypedDict _REPO_ROOT = Path(__file__).resolve().parents[1] _DOC_PATH = _REPO_ROOT / "docs" / "reference" / "mcp.md" _MARKER_RE_TEMPLATE = ( r"\n" r".*?\n" r"" ) class _ToolRecord(TypedDict): name: str description: str inputSchema: dict def _param_rows(input_schema: dict) -> str: """Render a tool's inputSchema as a Markdown parameter table, or a one-line note when the tool takes no parameters.""" props: dict = input_schema.get("properties") or {} if not props: return "_No parameters._" required: set[str] = set(input_schema.get("required") or []) lines = ["| Parameter | Type | Required | Description |", "|-----------|------|----------|-------------|"] for name in sorted(props): spec = props[name] ptype = spec.get("type", "any") if isinstance(ptype, list): ptype = " / ".join(ptype) req = "yes" if name in required else "no" desc = _escape_table_cell((spec.get("description") or "").replace("\n", " ").strip()) lines.append(f"| `{name}` | {ptype} | {req} | {desc} |") return "\n".join(lines) def _escape_table_cell(text: str) -> str: """Escape literal pipe characters so they don't split a Markdown table cell.""" return text.replace("|", "\\|") def _render_tool_entries(tools: list[_ToolRecord]) -> str: blocks = [] for t in sorted(tools, key=lambda t: t["name"]): desc = t["description"].replace("\n", " ").strip() blocks.append(f"#### `{t['name']}`\n\n{desc}\n\n{_param_rows(t['inputSchema'])}") return "\n\n---\n\n".join(blocks) def _classify_tools() -> tuple[list[_ToolRecord], list[_ToolRecord], list[_ToolRecord]]: """Partition MCP_TOOLS the same way the live dispatcher annotates them: elicitation-powered takes precedence over write, write over read.""" from musehub.mcp.tools import MCP_TOOLS, MUSEHUB_WRITE_TOOL_NAMES from musehub.mcp.tools.musehub import MUSEHUB_ELICITATION_TOOL_NAMES elicit: list[_ToolRecord] = [] write: list[_ToolRecord] = [] read: list[_ToolRecord] = [] for t in MCP_TOOLS: if t["name"] in MUSEHUB_ELICITATION_TOOL_NAMES: elicit.append(t) elif t["name"] in MUSEHUB_WRITE_TOOL_NAMES: write.append(t) else: read.append(t) return read, write, elicit def _render_resource_entries(resources: list[dict], *, uri_key: str) -> str: blocks = [] for r in sorted(resources, key=lambda r: r[uri_key]): desc = r["description"].replace("\n", " ").strip() blocks.append(f"#### `{r[uri_key]}`\n\n{desc}") return "\n\n---\n\n".join(blocks) def _replace_region(content: str, name: str, body: str) -> str: pattern = re.compile(_MARKER_RE_TEMPLATE.format(name=re.escape(name)), re.DOTALL) begin = f"" end = f"" replacement = f"{begin}\n{body}\n{end}" new_content, count = pattern.subn(replacement, content) if count != 1: raise ValueError(f"Expected exactly one '{name}' marker region, found {count}") return new_content def generate(doc_text: str) -> str: """Return *doc_text* with every GENERATED region regenerated from the live tool/resource registries.""" from musehub.mcp.resources import STATIC_RESOURCES, RESOURCE_TEMPLATES read, write, elicit = _classify_tools() total_tools = len(read) + len(write) + len(elicit) total_resources = len(STATIC_RESOURCES) + len(RESOURCE_TEMPLATES) doc_text = _replace_region( doc_text, "TOC-TOOLS", f"6. [Tools — {total_tools} total](#tools)\n" f" - [Read Tools ({len(read)})](#read-tools-{len(read)})\n" f" - [Write Tools ({len(write)})](#write-tools-{len(write)})\n" f" - [Elicitation-Powered Tools ({len(elicit)})](#elicitation-powered-tools-{len(elicit)})", ) doc_text = _replace_region( doc_text, "TOC-RESOURCES", f"7. [Resources — {total_resources} total](#resources)\n" f" - [Static Resources ({len(STATIC_RESOURCES)})](#static-resources-{len(STATIC_RESOURCES)})\n" f" - [Templated Resources ({len(RESOURCE_TEMPLATES)})](#templated-resources-{len(RESOURCE_TEMPLATES)})", ) doc_text = _replace_region( doc_text, "TOOLS-INTRO", f"All {total_tools} tools use `server_side: true`. The JSON-RPC envelope is " f"always a success response — errors are represented inside the content " f"block via `isError: true`.", ) doc_text = _replace_region( doc_text, "TOOLS-CATALOGUE", f"### Read Tools ({len(read)})\n\n" f"{_render_tool_entries(read)}\n\n---\n\n" f"### Write Tools ({len(write)})\n\n" f"{_render_tool_entries(write)}\n\n---\n\n" f"### Elicitation-Powered Tools ({len(elicit)})\n\n" f"{_render_tool_entries(elicit)}\n\n---", ) doc_text = _replace_region( doc_text, "RESOURCES-CATALOGUE", f"### Static Resources ({len(STATIC_RESOURCES)})\n\n" f"{_render_resource_entries(list(STATIC_RESOURCES), uri_key='uri')}\n\n---\n\n" f"### Templated Resources ({len(RESOURCE_TEMPLATES)})\n\n" f"All templated resources follow RFC 6570 Level 1. `{{owner}}` and `{{slug}}` " f"are resolved to a `repo_id` by the dispatcher — agents use human-readable " f"names, not UUIDs.\n\n" f"{_render_resource_entries(list(RESOURCE_TEMPLATES), uri_key='uriTemplate')}\n\n---", ) return doc_text def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--check", action="store_true", help="Check that the committed doc matches generated output. Exit 1 on drift.", ) args = parser.parse_args() if not _DOC_PATH.exists(): print(f"ERROR: {_DOC_PATH} does not exist", file=sys.stderr) sys.exit(1) committed = _DOC_PATH.read_text(encoding="utf-8") generated = generate(committed) if args.check: if committed == generated: print(f"✅ mcp.md is up to date ({_DOC_PATH})") return diff = list(difflib.unified_diff( committed.splitlines(keepends=True), generated.splitlines(keepends=True), fromfile="committed", tofile="generated", n=3, )) print( f"❌ mcp.md is out of date ({_DOC_PATH}).\n" f"Regenerate with: python scripts/gen_mcp_reference.py\n" f"\nDiff ({len(diff)} lines):", file=sys.stderr, ) sys.stderr.writelines(diff[:80]) if len(diff) > 80: print(f"... ({len(diff) - 80} more lines)", file=sys.stderr) sys.exit(1) _DOC_PATH.write_text(generated, encoding="utf-8") print(f"✅ Regenerated {_DOC_PATH}") if __name__ == "__main__": main()