gen_mcp_reference.py
python
sha256:b3e654170421616945cf1599d6bb4e8e219c3c2189458ed7f41af4b2d685f87a
docs: regenerate docs/reference/mcp.md's tool/resource cata…
Sonnet 5
patch
2 days ago
| 1 | #!/usr/bin/env python3 |
| 2 | """Regenerate the Tools/Resources catalogue sections of docs/reference/mcp.md. |
| 3 | |
| 4 | musehub#209: the hand-maintained doc drifted badly — only 38 of 127 live |
| 5 | tools were documented at all (not just stale counts), plus several |
| 6 | resources that no longer exist were still listed while newer ones were |
| 7 | missing entirely. Counting and transcribing ~150 tool/resource entries by |
| 8 | hand doesn't scale and re-drifts the moment anyone adds a tool without |
| 9 | remembering to also update this file. |
| 10 | |
| 11 | This script introspects the actual runtime registries — MCP_TOOLS, |
| 12 | MUSEHUB_WRITE_TOOL_NAMES, MUSEHUB_ELICITATION_TOOL_NAMES, STATIC_RESOURCES, |
| 13 | RESOURCE_TEMPLATES — the exact same data the live server returns from |
| 14 | ``tools/list``/``resources/list``/``resources/templates/list`` — and |
| 15 | regenerates the per-item catalogue sections plus their ToC/heading counts. |
| 16 | |
| 17 | Scope, deliberately: only the Tools and Resources catalogue sections are |
| 18 | generated. The Prompts section is NOT touched — at the time this script |
| 19 | was written every live prompt was already individually documented with |
| 20 | rich, hand-curated multi-step workflow guidance that the prompt registry's |
| 21 | plain description field can't reproduce; regenerating it would be a |
| 22 | content downgrade for zero coverage gain. All other sections (Architecture, |
| 23 | Transports, Authentication, Session Management, Elicitation, Error |
| 24 | Handling, Usage Patterns, Architecture Diagrams) are pure hand-written |
| 25 | prose and are never touched. |
| 26 | |
| 27 | Regenerated regions are delimited by HTML comment markers already present |
| 28 | in the doc: |
| 29 | |
| 30 | <!-- BEGIN GENERATED: <NAME> (scripts/gen_mcp_reference.py) --> |
| 31 | ... |
| 32 | <!-- END GENERATED: <NAME> --> |
| 33 | |
| 34 | Usage:: |
| 35 | |
| 36 | # Regenerate docs/reference/mcp.md in place: |
| 37 | python scripts/gen_mcp_reference.py |
| 38 | |
| 39 | # Check that the committed doc matches current source (CI gate): |
| 40 | python scripts/gen_mcp_reference.py --check |
| 41 | |
| 42 | Exit codes: |
| 43 | 0 — OK (or --check passed with no drift). |
| 44 | 1 — --check detected drift; see stderr for a diff summary. |
| 45 | """ |
| 46 | |
| 47 | from __future__ import annotations |
| 48 | |
| 49 | import argparse |
| 50 | import difflib |
| 51 | import re |
| 52 | import sys |
| 53 | from pathlib import Path |
| 54 | from typing import TypedDict |
| 55 | |
| 56 | _REPO_ROOT = Path(__file__).resolve().parents[1] |
| 57 | _DOC_PATH = _REPO_ROOT / "docs" / "reference" / "mcp.md" |
| 58 | |
| 59 | _MARKER_RE_TEMPLATE = ( |
| 60 | r"<!-- BEGIN GENERATED: {name} \(scripts/gen_mcp_reference\.py\) -->\n" |
| 61 | r".*?\n" |
| 62 | r"<!-- END GENERATED: {name} -->" |
| 63 | ) |
| 64 | |
| 65 | |
| 66 | class _ToolRecord(TypedDict): |
| 67 | name: str |
| 68 | description: str |
| 69 | inputSchema: dict |
| 70 | |
| 71 | |
| 72 | def _param_rows(input_schema: dict) -> str: |
| 73 | """Render a tool's inputSchema as a Markdown parameter table, or a |
| 74 | one-line note when the tool takes no parameters.""" |
| 75 | props: dict = input_schema.get("properties") or {} |
| 76 | if not props: |
| 77 | return "_No parameters._" |
| 78 | required: set[str] = set(input_schema.get("required") or []) |
| 79 | lines = ["| Parameter | Type | Required | Description |", "|-----------|------|----------|-------------|"] |
| 80 | for name in sorted(props): |
| 81 | spec = props[name] |
| 82 | ptype = spec.get("type", "any") |
| 83 | if isinstance(ptype, list): |
| 84 | ptype = " / ".join(ptype) |
| 85 | req = "yes" if name in required else "no" |
| 86 | desc = _escape_table_cell((spec.get("description") or "").replace("\n", " ").strip()) |
| 87 | lines.append(f"| `{name}` | {ptype} | {req} | {desc} |") |
| 88 | return "\n".join(lines) |
| 89 | |
| 90 | |
| 91 | def _escape_table_cell(text: str) -> str: |
| 92 | """Escape literal pipe characters so they don't split a Markdown table cell.""" |
| 93 | return text.replace("|", "\\|") |
| 94 | |
| 95 | |
| 96 | def _render_tool_entries(tools: list[_ToolRecord]) -> str: |
| 97 | blocks = [] |
| 98 | for t in sorted(tools, key=lambda t: t["name"]): |
| 99 | desc = t["description"].replace("\n", " ").strip() |
| 100 | blocks.append(f"#### `{t['name']}`\n\n{desc}\n\n{_param_rows(t['inputSchema'])}") |
| 101 | return "\n\n---\n\n".join(blocks) |
| 102 | |
| 103 | |
| 104 | def _classify_tools() -> tuple[list[_ToolRecord], list[_ToolRecord], list[_ToolRecord]]: |
| 105 | """Partition MCP_TOOLS the same way the live dispatcher annotates them: |
| 106 | elicitation-powered takes precedence over write, write over read.""" |
| 107 | from musehub.mcp.tools import MCP_TOOLS, MUSEHUB_WRITE_TOOL_NAMES |
| 108 | from musehub.mcp.tools.musehub import MUSEHUB_ELICITATION_TOOL_NAMES |
| 109 | |
| 110 | elicit: list[_ToolRecord] = [] |
| 111 | write: list[_ToolRecord] = [] |
| 112 | read: list[_ToolRecord] = [] |
| 113 | for t in MCP_TOOLS: |
| 114 | if t["name"] in MUSEHUB_ELICITATION_TOOL_NAMES: |
| 115 | elicit.append(t) |
| 116 | elif t["name"] in MUSEHUB_WRITE_TOOL_NAMES: |
| 117 | write.append(t) |
| 118 | else: |
| 119 | read.append(t) |
| 120 | return read, write, elicit |
| 121 | |
| 122 | |
| 123 | def _render_resource_entries(resources: list[dict], *, uri_key: str) -> str: |
| 124 | blocks = [] |
| 125 | for r in sorted(resources, key=lambda r: r[uri_key]): |
| 126 | desc = r["description"].replace("\n", " ").strip() |
| 127 | blocks.append(f"#### `{r[uri_key]}`\n\n{desc}") |
| 128 | return "\n\n---\n\n".join(blocks) |
| 129 | |
| 130 | |
| 131 | def _replace_region(content: str, name: str, body: str) -> str: |
| 132 | pattern = re.compile(_MARKER_RE_TEMPLATE.format(name=re.escape(name)), re.DOTALL) |
| 133 | begin = f"<!-- BEGIN GENERATED: {name} (scripts/gen_mcp_reference.py) -->" |
| 134 | end = f"<!-- END GENERATED: {name} -->" |
| 135 | replacement = f"{begin}\n{body}\n{end}" |
| 136 | new_content, count = pattern.subn(replacement, content) |
| 137 | if count != 1: |
| 138 | raise ValueError(f"Expected exactly one '{name}' marker region, found {count}") |
| 139 | return new_content |
| 140 | |
| 141 | |
| 142 | def generate(doc_text: str) -> str: |
| 143 | """Return *doc_text* with every GENERATED region regenerated from the |
| 144 | live tool/resource registries.""" |
| 145 | from musehub.mcp.resources import STATIC_RESOURCES, RESOURCE_TEMPLATES |
| 146 | |
| 147 | read, write, elicit = _classify_tools() |
| 148 | total_tools = len(read) + len(write) + len(elicit) |
| 149 | total_resources = len(STATIC_RESOURCES) + len(RESOURCE_TEMPLATES) |
| 150 | |
| 151 | doc_text = _replace_region( |
| 152 | doc_text, "TOC-TOOLS", |
| 153 | f"6. [Tools — {total_tools} total](#tools)\n" |
| 154 | f" - [Read Tools ({len(read)})](#read-tools-{len(read)})\n" |
| 155 | f" - [Write Tools ({len(write)})](#write-tools-{len(write)})\n" |
| 156 | f" - [Elicitation-Powered Tools ({len(elicit)})](#elicitation-powered-tools-{len(elicit)})", |
| 157 | ) |
| 158 | doc_text = _replace_region( |
| 159 | doc_text, "TOC-RESOURCES", |
| 160 | f"7. [Resources — {total_resources} total](#resources)\n" |
| 161 | f" - [Static Resources ({len(STATIC_RESOURCES)})](#static-resources-{len(STATIC_RESOURCES)})\n" |
| 162 | f" - [Templated Resources ({len(RESOURCE_TEMPLATES)})](#templated-resources-{len(RESOURCE_TEMPLATES)})", |
| 163 | ) |
| 164 | doc_text = _replace_region( |
| 165 | doc_text, "TOOLS-INTRO", |
| 166 | f"All {total_tools} tools use `server_side: true`. The JSON-RPC envelope is " |
| 167 | f"always a success response — errors are represented inside the content " |
| 168 | f"block via `isError: true`.", |
| 169 | ) |
| 170 | doc_text = _replace_region( |
| 171 | doc_text, "TOOLS-CATALOGUE", |
| 172 | f"### Read Tools ({len(read)})\n\n" |
| 173 | f"{_render_tool_entries(read)}\n\n---\n\n" |
| 174 | f"### Write Tools ({len(write)})\n\n" |
| 175 | f"{_render_tool_entries(write)}\n\n---\n\n" |
| 176 | f"### Elicitation-Powered Tools ({len(elicit)})\n\n" |
| 177 | f"{_render_tool_entries(elicit)}\n\n---", |
| 178 | ) |
| 179 | doc_text = _replace_region( |
| 180 | doc_text, "RESOURCES-CATALOGUE", |
| 181 | f"### Static Resources ({len(STATIC_RESOURCES)})\n\n" |
| 182 | f"{_render_resource_entries(list(STATIC_RESOURCES), uri_key='uri')}\n\n---\n\n" |
| 183 | f"### Templated Resources ({len(RESOURCE_TEMPLATES)})\n\n" |
| 184 | f"All templated resources follow RFC 6570 Level 1. `{{owner}}` and `{{slug}}` " |
| 185 | f"are resolved to a `repo_id` by the dispatcher — agents use human-readable " |
| 186 | f"names, not UUIDs.\n\n" |
| 187 | f"{_render_resource_entries(list(RESOURCE_TEMPLATES), uri_key='uriTemplate')}\n\n---", |
| 188 | ) |
| 189 | return doc_text |
| 190 | |
| 191 | |
| 192 | def main() -> None: |
| 193 | parser = argparse.ArgumentParser(description=__doc__) |
| 194 | parser.add_argument( |
| 195 | "--check", action="store_true", |
| 196 | help="Check that the committed doc matches generated output. Exit 1 on drift.", |
| 197 | ) |
| 198 | args = parser.parse_args() |
| 199 | |
| 200 | if not _DOC_PATH.exists(): |
| 201 | print(f"ERROR: {_DOC_PATH} does not exist", file=sys.stderr) |
| 202 | sys.exit(1) |
| 203 | |
| 204 | committed = _DOC_PATH.read_text(encoding="utf-8") |
| 205 | generated = generate(committed) |
| 206 | |
| 207 | if args.check: |
| 208 | if committed == generated: |
| 209 | print(f"✅ mcp.md is up to date ({_DOC_PATH})") |
| 210 | return |
| 211 | diff = list(difflib.unified_diff( |
| 212 | committed.splitlines(keepends=True), |
| 213 | generated.splitlines(keepends=True), |
| 214 | fromfile="committed", tofile="generated", n=3, |
| 215 | )) |
| 216 | print( |
| 217 | f"❌ mcp.md is out of date ({_DOC_PATH}).\n" |
| 218 | f"Regenerate with: python scripts/gen_mcp_reference.py\n" |
| 219 | f"\nDiff ({len(diff)} lines):", |
| 220 | file=sys.stderr, |
| 221 | ) |
| 222 | sys.stderr.writelines(diff[:80]) |
| 223 | if len(diff) > 80: |
| 224 | print(f"... ({len(diff) - 80} more lines)", file=sys.stderr) |
| 225 | sys.exit(1) |
| 226 | |
| 227 | _DOC_PATH.write_text(generated, encoding="utf-8") |
| 228 | print(f"✅ Regenerated {_DOC_PATH}") |
| 229 | |
| 230 | |
| 231 | if __name__ == "__main__": |
| 232 | main() |
File History
1 commit
sha256:b3e654170421616945cf1599d6bb4e8e219c3c2189458ed7f41af4b2d685f87a
docs: regenerate docs/reference/mcp.md's tool/resource cata…
Sonnet 5
patch
2 days ago