gabriel / musehub public
gen_mcp_reference.py python
232 lines 9.0 KB
Raw
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