#!/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 = "" END_MARKER = "" 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())