245 lines
9.8 KiB
Python
245 lines
9.8 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Regenerate the MCP tool catalogue inside `docs/api-mcp-layer.md` from the
|
||
|
|
live `tools` array in `src/app/api/mcp/route.ts`.
|
||
|
|
|
||
|
|
The script is intentionally regex-based (not a JS parser) because the tool
|
||
|
|
shape is small and stable — the goal is "drift-free docs", not "AST-perfect
|
||
|
|
extraction". Anything the script can't parse falls into the `UNPARSED` list
|
||
|
|
below so a human knows to fix the script.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python3 scripts/mcp-doc-gen.py # writes to docs/api-mcp-layer.md
|
||
|
|
python3 scripts/mcp-doc-gen.py --dry-run # prints diff to stdout
|
||
|
|
python3 scripts/mcp-doc-gen.py --check # exit 1 if the file is out of date
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
ROUTE_TS = REPO_ROOT / "src/app/api/mcp/route.ts"
|
||
|
|
DOC_FILE = REPO_ROOT / "docs/api-mcp-layer.md"
|
||
|
|
|
||
|
|
# Begin / end markers for the auto-generated block. Everything between
|
||
|
|
# the begin and end markers is replaced wholesale; everything outside is
|
||
|
|
# preserved verbatim. The begin marker is followed by a 5-line header so
|
||
|
|
# the doc stays readable even when the script is rerun.
|
||
|
|
BEGIN_MARKER = "<!-- BEGIN mcp-doc-gen: tools catalogue — do not edit by hand, regenerate with scripts/mcp-doc-gen.py -->"
|
||
|
|
END_MARKER = "<!-- END mcp-doc-gen -->"
|
||
|
|
|
||
|
|
|
||
|
|
def parse_tools(route_text: str) -> list[dict]:
|
||
|
|
"""Extract `{ name, description, inputSchema }` from the tools array.
|
||
|
|
|
||
|
|
The tool descriptions are JS string literals that may be concatenated
|
||
|
|
(`"foo" + "bar"`) or use template literals. We collapse both forms
|
||
|
|
before stopping at the matching `inputSchema: {` block.
|
||
|
|
"""
|
||
|
|
# Find the start of the `const tools = [...]` array.
|
||
|
|
array_match = re.search(r"const tools\s*=\s*\[", route_text)
|
||
|
|
if not array_match:
|
||
|
|
raise SystemExit("Could not find `const tools = [...]` in route.ts")
|
||
|
|
start = array_match.end()
|
||
|
|
# Walk the array tracking string-literal depth so we don't bail on
|
||
|
|
# nested arrays in inputSchema. The whole file is one giant string,
|
||
|
|
# so we walk char-by-char.
|
||
|
|
tools: list[dict] = []
|
||
|
|
i = start
|
||
|
|
while i < len(route_text):
|
||
|
|
# Look for the next tool entry: `{ name: "..."`.
|
||
|
|
m = re.search(r"\{\s*name:\s*\"([^\"]+)\"", route_text[i:])
|
||
|
|
if not m:
|
||
|
|
break
|
||
|
|
name = m.group(1)
|
||
|
|
# Skip ahead past the name. m.end() is relative to text[i:],
|
||
|
|
# so adding it to i gives the absolute position right after the
|
||
|
|
# closing quote of the name.
|
||
|
|
entry_start = i + m.start()
|
||
|
|
i += m.end()
|
||
|
|
# Find the description block. Three shapes:
|
||
|
|
# description: "literal text"
|
||
|
|
# description: "first segment" + "second segment" + ...
|
||
|
|
# description: `template literal`
|
||
|
|
# A comma may or may not separate `name` from `description` (looking
|
||
|
|
# at the route.ts source, the convention is no comma between the two
|
||
|
|
# top-level keys but a comma before `inputSchema`).
|
||
|
|
desc_match = re.match(r"\s*,\s*description:\s*", route_text[i:])
|
||
|
|
if not desc_match:
|
||
|
|
# Malformed entry — skip past it.
|
||
|
|
i = entry_start + 2
|
||
|
|
continue
|
||
|
|
i += desc_match.end()
|
||
|
|
if route_text[i] == "`":
|
||
|
|
# Template literal — read until the closing backtick.
|
||
|
|
close = route_text.find("`", i + 1)
|
||
|
|
if close == -1:
|
||
|
|
raise SystemExit(f"Unterminated template literal for {name}")
|
||
|
|
desc = route_text[i + 1 : close]
|
||
|
|
i = close + 1
|
||
|
|
elif route_text[i] == '"':
|
||
|
|
desc = ""
|
||
|
|
# Walk through what may be several concatenated `"..."` + ... forms.
|
||
|
|
while True:
|
||
|
|
# Find the closing quote, skipping backslash escapes.
|
||
|
|
j = i + 1
|
||
|
|
while j < len(route_text):
|
||
|
|
if route_text[j] == "\\":
|
||
|
|
j += 2
|
||
|
|
continue
|
||
|
|
if route_text[j] == '"':
|
||
|
|
break
|
||
|
|
j += 1
|
||
|
|
if j >= len(route_text):
|
||
|
|
raise SystemExit(f"Unterminated string for {name}")
|
||
|
|
desc += route_text[i + 1 : j]
|
||
|
|
i = j + 1
|
||
|
|
# Skip whitespace + `+` chains.
|
||
|
|
while i < len(route_text) and route_text[i] in " \t\n\r":
|
||
|
|
i += 1
|
||
|
|
if i < len(route_text) and route_text[i] == "+":
|
||
|
|
i += 1
|
||
|
|
while i < len(route_text) and route_text[i] in " \t\n\r":
|
||
|
|
i += 1
|
||
|
|
if i < len(route_text) and route_text[i] == '"':
|
||
|
|
continue
|
||
|
|
break
|
||
|
|
else:
|
||
|
|
# Malformed entry — bail.
|
||
|
|
i = entry_start + 2
|
||
|
|
continue
|
||
|
|
# Unescape JS string escapes.
|
||
|
|
desc = (
|
||
|
|
desc.replace("\\n", "\n")
|
||
|
|
.replace("\\t", " ")
|
||
|
|
.replace('\\"', '"')
|
||
|
|
.replace("\\\\", "\\")
|
||
|
|
)
|
||
|
|
# Collapse whitespace to single spaces for the table summary.
|
||
|
|
desc_short = re.sub(r"\s+", " ", desc).strip()
|
||
|
|
if len(desc_short) > 240:
|
||
|
|
desc_short = desc_short[:237] + "..."
|
||
|
|
tools.append({"name": name, "description": desc_short, "description_full": desc.strip()})
|
||
|
|
return tools
|
||
|
|
|
||
|
|
|
||
|
|
def render_catalogue(tools: list[dict]) -> str:
|
||
|
|
"""Render the markdown block between the begin/end markers."""
|
||
|
|
lines = [
|
||
|
|
BEGIN_MARKER,
|
||
|
|
"",
|
||
|
|
f"This catalogue is auto-generated from the `tools` array in "
|
||
|
|
f"`src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. "
|
||
|
|
f"There are **{len(tools)} tools** as of this run. "
|
||
|
|
f"Re-run the script after adding or renaming a tool.\n",
|
||
|
|
"Grouped by resource family. The full description is in the table "
|
||
|
|
"column; the description text the agent sees is preserved verbatim.\n",
|
||
|
|
]
|
||
|
|
groups = [
|
||
|
|
("orders", "Orders"),
|
||
|
|
("order_items", "Order items"),
|
||
|
|
("quotes", "Quotes"),
|
||
|
|
("quote_items", "Quote items"),
|
||
|
|
("attachments", "Attachments"),
|
||
|
|
("companies", "Companies"),
|
||
|
|
("customers", "Customers"),
|
||
|
|
("suppliers", "Suppliers"),
|
||
|
|
("purchase_orders", "Purchase orders"),
|
||
|
|
("stock_purchases", "Stock purchases"),
|
||
|
|
("stock_purchase_items", "Stock purchase items"),
|
||
|
|
("stock_items", "Stock items"),
|
||
|
|
("stock_fulfill", "Stock fulfilment"),
|
||
|
|
("stock_surplus", "Stock surplus"),
|
||
|
|
("collection_pool", "Collection pool"),
|
||
|
|
("collection_confirm", "Collection confirm"),
|
||
|
|
("collection_map", "Collection map"),
|
||
|
|
("delivery", "Deliveries"),
|
||
|
|
("driver", "Driver"),
|
||
|
|
("sprint", "Sprint"),
|
||
|
|
("buyer_tasks", "Buyer tasks"),
|
||
|
|
("price_list", "Price list"),
|
||
|
|
("ai", "AI / Insights"),
|
||
|
|
("users", "Users"),
|
||
|
|
("files", "Files"),
|
||
|
|
("bulk_update", "Bulk updates"),
|
||
|
|
("categories", "Categories"),
|
||
|
|
("dashboard", "Dashboard"),
|
||
|
|
("purchase_order_agent", "PO agent"),
|
||
|
|
("misc", "Misc"),
|
||
|
|
]
|
||
|
|
# Group tools by their first dot-separated segment.
|
||
|
|
by_group: dict[str, list[dict]] = {g[0]: [] for g in groups}
|
||
|
|
for t in tools:
|
||
|
|
prefix = t["name"].split(".")[0]
|
||
|
|
if prefix in by_group:
|
||
|
|
by_group[prefix].append(t)
|
||
|
|
else:
|
||
|
|
by_group.setdefault("misc", []).append(t)
|
||
|
|
# Render each group in the defined order; skip empties.
|
||
|
|
for prefix, title in groups:
|
||
|
|
items = by_group.get(prefix) or []
|
||
|
|
if not items:
|
||
|
|
continue
|
||
|
|
lines.append(f"\n### {title}\n")
|
||
|
|
lines.append("| Tool | Description |")
|
||
|
|
lines.append("| --- | --- |")
|
||
|
|
for t in items:
|
||
|
|
# Escape pipes in the description so the table stays readable.
|
||
|
|
desc = t["description"].replace("|", "\\|").replace("\n", " ")
|
||
|
|
lines.append(f"| `{t['name']}` | {desc} |")
|
||
|
|
# Alphabetical index — one line so it scans quickly.
|
||
|
|
sorted_names = sorted(t["name"] for t in tools)
|
||
|
|
lines.append("\n### Alphabetical index\n")
|
||
|
|
lines.append("`" + "`, `".join(sorted_names) + "`\n")
|
||
|
|
lines.append(END_MARKER)
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def regenerate_doc(doc_text: str, tools: list[dict]) -> str:
|
||
|
|
catalogue = render_catalogue(tools)
|
||
|
|
if BEGIN_MARKER not in doc_text or END_MARKER not in doc_text:
|
||
|
|
# First run — append the markers to the end of the file.
|
||
|
|
return doc_text.rstrip() + "\n\n---\n\n## 5. MCP tool catalogue (auto-generated)\n\n" + catalogue
|
||
|
|
# Replace the block between markers. The markers are on their own lines
|
||
|
|
# so a regex with the DOTALL flag is sufficient.
|
||
|
|
pattern = re.compile(
|
||
|
|
re.escape(BEGIN_MARKER) + r".*?" + re.escape(END_MARKER),
|
||
|
|
re.DOTALL,
|
||
|
|
)
|
||
|
|
return pattern.sub(catalogue.rstrip("\n"), doc_text)
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--dry-run", action="store_true", help="Print the new catalogue to stdout instead of writing")
|
||
|
|
parser.add_argument("--check", action="store_true", help="Exit 1 if regenerating would change the file")
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
route_text = ROUTE_TS.read_text()
|
||
|
|
tools = parse_tools(route_text)
|
||
|
|
print(f"Parsed {len(tools)} tools from {ROUTE_TS}", file=sys.stderr)
|
||
|
|
|
||
|
|
doc_text = DOC_FILE.read_text()
|
||
|
|
new_text = regenerate_doc(doc_text, tools)
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
print(new_text)
|
||
|
|
return 0
|
||
|
|
if args.check:
|
||
|
|
if new_text == doc_text:
|
||
|
|
print("docs/api-mcp-layer.md is up to date", file=sys.stderr)
|
||
|
|
return 0
|
||
|
|
print("docs/api-mcp-layer.md is OUT OF DATE — run scripts/mcp-doc-gen.py", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
DOC_FILE.write_text(new_text)
|
||
|
|
print(f"Wrote {DOC_FILE} ({len(tools)} tools)", file=sys.stderr)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|