Move scripts to docgen/, remove possess_flow.png

- docgen/: generate.py, ue_parser.py, ue_markdown.py, ue_mcp_server.py
- .mcp.json: update server path to docgen/ue_mcp_server.py
- Update CLAUDE.md and README paths accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 08:26:47 -05:00
parent 5c1b5bf3b8
commit 217f1f99dd
8 changed files with 19 additions and 19 deletions

149
docgen/generate.py Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
generate.py — CLI for UnrealDocGenerator.
Usage:
python generate.py <input> [input2 ...] <output_dir>
Each <input> can be a single .h file or a directory (processed recursively).
The last argument is always the output directory.
Two-pass pipeline:
Pass 1 — parse every header, build a corpus-wide type index
Pass 2 — render each header with cross-reference links injected
"""
import sys
import os
import re
from pathlib import Path
from ue_parser import parse_header, ParsedHeader
from ue_markdown import render_header
# ---------------------------------------------------------------------------
# Input collection
# ---------------------------------------------------------------------------
def collect_headers(input_arg: Path) -> list[tuple[Path, Path]]:
"""
Returns a list of (header_path, base_path) pairs for the given input.
base_path is used to compute relative output paths.
"""
if input_arg.is_file():
return [(input_arg, input_arg.parent)]
elif input_arg.is_dir():
return [(h, input_arg) for h in sorted(input_arg.rglob('*.h'))]
else:
print(f"Error: {input_arg} is not a file or directory", file=sys.stderr)
return []
# ---------------------------------------------------------------------------
# Type index
# ---------------------------------------------------------------------------
def build_type_index(parsed_list: list[tuple[Path, Path, ParsedHeader]]) -> dict[str, str]:
"""
Returns {TypeName: md_path_relative_to_docs_root} for every
class, struct, enum, and delegate in the corpus.
"""
index: dict[str, str] = {}
for h, base, parsed in parsed_list:
md_rel = _md_rel(h, base)
for ci in parsed.classes:
index[ci.name] = md_rel
for ei in parsed.enums:
index[ei.name] = md_rel
for di in parsed.delegates:
index[di.name] = md_rel
# namespace names are not types — skip
return index
def _md_rel(h: Path, base: Path) -> str:
"""Relative .md path for header h given its input base."""
try:
rel = h.relative_to(base)
except ValueError:
rel = Path(h.name)
return str(rel.with_suffix('.md'))
# ---------------------------------------------------------------------------
# Type index file
# ---------------------------------------------------------------------------
def write_type_index(type_index: dict[str, str], output_dir: Path) -> None:
"""
Write docs/type-index.txt — compact TypeName: path/to/File.md lookup.
One entry per line, alphabetically sorted. Agents can grep this file
to resolve a type name to its documentation path.
"""
_valid_name = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
lines = sorted(f"{name}: {path}" for name, path in type_index.items()
if _valid_name.match(name))
out = output_dir / "type-index.txt"
out.write_text('\n'.join(lines) + '\n', encoding='utf-8')
print(f"Written {out}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 3:
print("Usage: python generate.py <input> [input2 ...] <output_dir>", file=sys.stderr)
sys.exit(1)
*input_args, output_arg = sys.argv[1:]
output_dir = Path(output_arg)
output_dir.mkdir(parents=True, exist_ok=True)
# Collect (header, base) pairs from all inputs
header_pairs: list[tuple[Path, Path]] = []
for arg in input_args:
pairs = collect_headers(Path(arg))
if not pairs:
print(f"Warning: no .h files found in {arg}", file=sys.stderr)
header_pairs.extend(pairs)
if not header_pairs:
print("No .h files found.", file=sys.stderr)
sys.exit(1)
# --- Pass 1: parse all ---
parsed_list: list[tuple[Path, Path, ParsedHeader]] = []
for h, base in header_pairs:
print(f"Parsing {h} ...")
try:
parsed = parse_header(str(h))
parsed_list.append((h, base, parsed))
except Exception as exc:
print(f" ERROR parsing {h}: {exc}", file=sys.stderr)
# --- Build corpus-wide type index ---
type_index = build_type_index(parsed_list)
# --- Pass 2: render all ---
success = 0
for h, base, parsed in parsed_list:
print(f"Rendering {h} ...")
current_md = _md_rel(h, base)
out_path = output_dir / current_md
out_path.parent.mkdir(parents=True, exist_ok=True)
try:
md = render_header(parsed, type_index=type_index, current_md=current_md)
out_path.write_text(md, encoding='utf-8')
success += 1
except Exception as exc:
print(f" ERROR rendering {h}: {exc}", file=sys.stderr)
write_type_index(type_index, output_dir)
print(f"\nGenerated {success}/{len(parsed_list)} files + type-index.txt in {output_dir}/")
if __name__ == '__main__':
main()

405
docgen/ue_markdown.py Normal file
View File

@@ -0,0 +1,405 @@
"""
ue_markdown.py — Render a ParsedHeader into compact, agent-readable Markdown.
Format goals:
- Ultra-compact function entries: signature in heading, params folded into prose
- Bullet-list properties (no tables)
- Only items with actual C++ doc comments (or deprecated annotations) are emitted
- Cross-reference links to other files in the corpus (via type_index)
"""
from __future__ import annotations
import os
import re
from typing import Optional
from ue_parser import (
ParsedHeader, ClassInfo, EnumInfo, DelegateInfo, NamespaceInfo,
FreeFunction, FunctionInfo, PropertyInfo, DocComment, _split_params,
)
# ---------------------------------------------------------------------------
# Cross-reference utilities
# ---------------------------------------------------------------------------
def _rel_link(current_md: str, target_md: str) -> str:
"""
Compute a relative markdown link from the directory of current_md to target_md.
Both paths are relative to the docs root.
"""
cur_dir = os.path.dirname(current_md) or '.'
return os.path.relpath(target_md, cur_dir).replace('\\', '/')
def _make_type_link(name: str, type_index: dict[str, str],
current_md: str) -> str:
"""Return '[Name](relative/path.md)' if in corpus, else '`Name`'."""
if name in type_index:
link = _rel_link(current_md, type_index[name])
return f"[{name}]({link})"
return f"`{name}`"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _has_doc(comment: Optional[DocComment]) -> bool:
if comment is None:
return False
return bool(comment.description or comment.params or comment.returns)
def _uf_category(uf_specs: str) -> str:
m = re.search(r'Category\s*=\s*"([^"]*)"', uf_specs)
if m:
return m.group(1)
m = re.search(r'Category\s*=\s*([\w|]+)', uf_specs)
return m.group(1) if m else ""
def _uf_flags(uf_specs: str) -> list:
flags = []
for flag in ('BlueprintCallable', 'BlueprintPure', 'BlueprintImplementableEvent',
'BlueprintNativeEvent', 'Exec', 'NetMulticast', 'Server', 'Client',
'CallInEditor'):
if re.search(r'\b' + flag + r'\b', uf_specs):
flags.append(flag)
return flags
def _up_flags(specs: str) -> list:
notable = [
'EditAnywhere', 'EditDefaultsOnly', 'EditInstanceOnly',
'VisibleAnywhere', 'VisibleDefaultsOnly',
'BlueprintReadWrite', 'BlueprintReadOnly', 'BlueprintAssignable',
'Replicated', 'ReplicatedUsing', 'Transient', 'SaveGame', 'Config',
]
return [f for f in notable if re.search(r'\b' + f + r'\b', specs)]
def _compact_params(raw_params: str, max_count: int = 3) -> str:
params = _split_params(raw_params)
if not params:
return ""
shown = []
for p in params[:max_count]:
p = p.strip()
p = re.sub(r'\s*=\s*', '=', p)
shown.append(p)
result = ', '.join(shown)
if len(params) > max_count:
result += ', ...'
return result
def _fn_flags_str(fn: FunctionInfo) -> str:
parts = []
if fn.is_deprecated:
ver = fn.deprecated_version or "?"
parts.append(f"Deprecated {ver}")
if fn.uf_specifiers:
uf = _uf_flags(fn.uf_specifiers)
cat = _uf_category(fn.uf_specifiers)
annotation = ', '.join(uf)
if cat:
annotation = (annotation + '' if annotation else '') + cat
if annotation:
parts.append(annotation)
for mod in fn.modifiers:
if mod in ('virtual', 'static', 'inline', 'constexpr'):
parts.append(mod)
if fn.editor_only:
parts.append("Editor only")
return f" *({', '.join(parts)})*" if parts else ""
_PLACEHOLDER_RE = re.compile(r'^[-_/\\]{6,}')
def _fn_body(fn: FunctionInfo) -> str:
parts = []
c = fn.comment
if c:
if c.description and not _PLACEHOLDER_RE.match(c.description):
desc = c.description.rstrip('.')
parts.append(desc + '.')
for pname, pdesc in c.params.items():
pdesc = pdesc.rstrip('.')
parts.append(f"`{pname}`: {pdesc}.")
if c.returns:
ret = c.returns.rstrip('.')
parts.append(f"**Returns** {ret}.")
return ' '.join(parts)
def _render_function_compact(fn: FunctionInfo) -> str:
params_str = _compact_params(fn.raw_params)
heading = f"##### `{fn.name}({params_str})`"
if fn.return_type:
heading += f" → `{fn.return_type}`"
heading += _fn_flags_str(fn)
body = _fn_body(fn)
return heading + ('\n' + body if body else '')
def _render_ff_compact(fn: FreeFunction, overloads: list = None) -> str:
overloads = overloads or [fn]
fn0 = overloads[0]
params_str = _compact_params(fn0.raw_params)
heading = f"##### `{fn0.name}({params_str})`"
if fn0.return_type:
heading += f" → `{fn0.return_type}`"
flag_parts = [m for m in fn0.modifiers if m in ('inline', 'static', 'constexpr')]
if len(overloads) > 1:
flag_parts.append(f"{len(overloads)} overloads")
if flag_parts:
heading += f" *({', '.join(flag_parts)})*"
body = ""
for f in overloads:
b = _fn_body(f)
if b:
body = b
break
return heading + ('\n' + body if body else '')
# ---------------------------------------------------------------------------
# Section renderers
# ---------------------------------------------------------------------------
def _render_delegates(delegates: list) -> str:
if not delegates:
return ""
lines = ["## Delegates"]
for d in delegates:
suffix_parts = []
if d.is_dynamic:
suffix_parts.append("Dynamic")
if d.is_multicast:
suffix_parts.append("Multicast")
suffix = ", ".join(suffix_parts)
head = f"### `{d.name}`"
if suffix:
head += f" *({suffix})*"
lines.append(head)
if d.comment and d.comment.description:
lines.append(d.comment.description)
if d.params:
param_str = ", ".join(f"`{t} {n}`" for t, n in d.params)
lines.append(f"Params: {param_str}")
return '\n'.join(lines)
def _render_enum(ei: EnumInfo) -> str:
head = f"### `{ei.name}`"
if ei.editor_only:
head += " *(Editor only)*"
lines = [head]
if ei.comment and ei.comment.description:
lines.append(ei.comment.description)
if ei.values:
has_descriptions = any(v.comment for v in ei.values)
if has_descriptions:
lines.append("| Value | Description |")
lines.append("|-------|-------------|")
for v in ei.values:
val_str = f"`{v.name}`"
if v.value:
val_str += f" (={v.value})"
lines.append(f"| {val_str} | {v.comment} |")
else:
# Compact inline list when no value has a description
vals = [v.name for v in ei.values]
lines.append("Values: " + ", ".join(f"`{v}`" for v in vals))
return '\n'.join(lines)
def _render_enums(enums: list, heading: str = "## Enums") -> str:
if not enums:
return ""
parts = [heading]
for ei in enums:
parts.append(_render_enum(ei))
return '\n'.join(parts)
def _render_properties(props: list) -> str:
visible = [p for p in props
if p.access in ('public', 'protected') and _has_doc(p.comment)]
if not visible:
return ""
lines = ["#### Properties"]
for p in visible:
flags = ', '.join(_up_flags(p.specifiers))
desc = p.comment.description if p.comment else ""
line = f"- `{p.name}` `{p.type}`"
if flags:
line += f" *({flags})*"
if desc:
line += f"{desc}"
if p.editor_only:
line += " *(Editor only)*"
lines.append(line)
return '\n'.join(lines)
def _render_functions(fns: list) -> str:
visible = [f for f in fns
if f.access in ('public', 'protected')
and _has_doc(f.comment)
and not f.is_deprecated]
if not visible:
return ""
lines = ["#### Functions"]
seen: dict[str, list] = {}
ordered: list[str] = []
for fn in visible:
if fn.name not in seen:
seen[fn.name] = []
ordered.append(fn.name)
seen[fn.name].append(fn)
for name in ordered:
group = seen[name]
if len(group) == 1:
lines.append(_render_function_compact(group[0]))
else:
fn0 = group[0]
params_str = _compact_params(fn0.raw_params)
heading = f"##### `{fn0.name}({params_str})`"
if fn0.return_type:
heading += f" → `{fn0.return_type}`"
flag_parts = []
if fn0.is_deprecated:
flag_parts.append(f"Deprecated {fn0.deprecated_version or '?'}")
if fn0.uf_specifiers:
uf = _uf_flags(fn0.uf_specifiers)
cat = _uf_category(fn0.uf_specifiers)
ann = ', '.join(uf)
if cat:
ann = (ann + '' if ann else '') + cat
if ann:
flag_parts.append(ann)
flag_parts.append(f"{len(group)} overloads")
heading += f" *({', '.join(flag_parts)})*"
lines.append(heading)
for fn in group:
body = _fn_body(fn)
if body:
lines.append(body)
break
return '\n'.join(lines)
def _class_has_content(ci: ClassInfo) -> bool:
if _has_doc(ci.comment):
return True
if any(p.access in ('public', 'protected') and _has_doc(p.comment)
for p in ci.properties):
return True
if any(f.access in ('public', 'protected') and (_has_doc(f.comment) or f.is_deprecated)
for f in ci.functions):
return True
if ci.nested_enums:
return True
return False
def _render_class(ci: ClassInfo,
type_index: dict[str, str], current_md: str) -> str:
lines = []
head = f"### `{ci.name}` *({ci.kind})*"
lines.append(head)
if ci.bases:
linked = [_make_type_link(b, type_index, current_md) for b in ci.bases]
lines.append("*Inherits*: " + ", ".join(linked))
if ci.module_api:
lines.append(f"*API*: `{ci.module_api}`")
if ci.comment and ci.comment.description:
lines.append(ci.comment.description)
if ci.nested_enums:
lines.append(_render_enums(ci.nested_enums, "#### Enums"))
props_section = _render_properties(ci.properties)
if props_section:
lines.append(props_section)
fns_section = _render_functions(ci.functions)
if fns_section:
lines.append(fns_section)
return '\n'.join(lines)
def _render_namespace(ns: NamespaceInfo) -> str:
doc_fns = [f for f in ns.functions if _has_doc(f.comment)]
if not doc_fns:
return ""
lines = [f"## Free Functions — `{ns.name}`"]
seen: dict[str, list] = {}
ordered: list[str] = []
for fn in doc_fns:
if fn.name not in seen:
seen[fn.name] = []
ordered.append(fn.name)
seen[fn.name].append(fn)
for name in ordered:
group = seen[name]
lines.append(_render_ff_compact(group[0], group))
return '\n'.join(lines)
# ---------------------------------------------------------------------------
# Main render function
# ---------------------------------------------------------------------------
def render_header(parsed: ParsedHeader,
type_index: dict[str, str] = None,
current_md: str = "") -> str:
if type_index is None:
type_index = {}
lines = []
lines.append(f"# `{parsed.filename}`")
lines.append(f"**Module**: `{parsed.module_name}`")
lines.append("")
sections = []
d_sec = _render_delegates(parsed.delegates)
if d_sec.strip():
sections.append(d_sec)
e_sec = _render_enums(parsed.enums)
if e_sec.strip():
sections.append(e_sec)
if parsed.classes:
doc_classes = [ci for ci in parsed.classes if _class_has_content(ci)]
if doc_classes:
cls_lines = ["## Classes"]
for ci in doc_classes:
cls_lines.append(_render_class(ci, type_index, current_md))
sections.append('\n'.join(cls_lines))
for ns in parsed.namespaces:
ns_sec = _render_namespace(ns)
if ns_sec.strip():
sections.append(ns_sec)
if parsed.free_functions:
doc_fns = [f for f in parsed.free_functions if _has_doc(f.comment)]
if doc_fns:
ff_lines = ["## Free Functions"]
for fn in doc_fns:
ff_lines.append(_render_ff_compact(fn))
sections.append('\n'.join(ff_lines))
lines.append('\n\n---\n\n'.join(sections))
return '\n'.join(lines)

213
docgen/ue_mcp_server.py Normal file
View File

@@ -0,0 +1,213 @@
"""MCP server exposing UE documentation tools at item granularity."""
import os
import re
import subprocess
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ue-docs")
# ---------------------------------------------------------------------------
# Env helpers
# ---------------------------------------------------------------------------
def _docs_root() -> Path:
p = os.environ.get("UE_DOCS_PATH", "")
if not p:
raise RuntimeError("UE_DOCS_PATH environment variable not set")
return Path(p)
def _engine_root() -> Path:
p = os.environ.get("UE_ENGINE_ROOT", "")
if not p:
raise RuntimeError("UE_ENGINE_ROOT environment variable not set")
return Path(p)
def _load_type_index() -> dict[str, str]:
"""Returns {TypeName: relative_md_path}."""
idx = (_docs_root() / "type-index.txt").read_text()
result = {}
for line in idx.splitlines():
if ": " in line:
name, path = line.split(": ", 1)
result[name.strip()] = path.strip()
return result
# ---------------------------------------------------------------------------
# Section extraction helpers
# ---------------------------------------------------------------------------
def _extract_class_section(text: str, class_name: str) -> str:
"""Return the markdown section for class_name (### `ClassName` … next ### `)."""
# Match the heading for this class
pattern = rf'^### `{re.escape(class_name)}`'
m = re.search(pattern, text, re.MULTILINE)
if not m:
return ""
start = m.start()
# Find the next same-level heading
next_h3 = re.search(r'^### `', text[m.end():], re.MULTILINE)
if next_h3:
end = m.end() + next_h3.start()
else:
end = len(text)
return text[start:end]
def _build_overview(section: str) -> str:
"""Compact overview: header block + name lists for Properties and Functions."""
lines = section.splitlines()
prop_names: list[str] = []
func_names: list[str] = [] # deduped
seen_funcs: set[str] = set()
header_lines: list[str] = []
in_props = False
in_funcs = False
for line in lines:
if line.startswith("#### Properties"):
in_props = True
in_funcs = False
continue
if line.startswith("#### Functions"):
in_funcs = True
in_props = False
continue
if line.startswith("#### ") or line.startswith("### "):
# Some other subsection — stop collecting
in_props = False
in_funcs = False
if in_props:
m = re.match(r"- `(\w+)`", line)
if m:
prop_names.append(m.group(1))
elif in_funcs:
m = re.match(r"##### `(\w+)\(", line)
if m:
name = m.group(1)
if name not in seen_funcs:
seen_funcs.add(name)
func_names.append(name)
else:
header_lines.append(line)
# Trim trailing blank lines from header
while header_lines and not header_lines[-1].strip():
header_lines.pop()
parts = ["\n".join(header_lines)]
if prop_names:
parts.append(f"\nProperties ({len(prop_names)}): {', '.join(prop_names)}")
if func_names:
parts.append(f"Functions ({len(func_names)}): {', '.join(func_names)}")
return "\n".join(parts)
def _extract_member(section: str, member_name: str) -> str:
"""Return the full doc entry for member_name (all overloads for functions)."""
escaped = re.escape(member_name)
# Try function first (##### `Name(`)
pattern = rf'^##### `{escaped}\('
m = re.search(pattern, section, re.MULTILINE)
if m:
start = m.start()
# Collect until the next ##### ` heading
next_h5 = re.search(r'^##### `', section[m.end():], re.MULTILINE)
if next_h5:
end = m.end() + next_h5.start()
else:
end = len(section)
return section[start:end].rstrip()
# Try property (- `Name`)
m = re.search(rf'^- `{escaped}`', section, re.MULTILINE)
if m:
return section[m.start():section.index("\n", m.start())].rstrip()
return ""
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@mcp.tool()
def search_types(pattern: str) -> str:
"""Search type-index.txt for UE types whose name matches pattern (case-insensitive).
Returns matching 'TypeName: path/to/File.md' lines (up to 50).
Use this to locate a type before calling get_class_overview or get_file."""
idx = _docs_root() / "type-index.txt"
lines = [l for l in idx.read_text().splitlines()
if re.search(pattern, l, re.IGNORECASE)]
return "\n".join(lines[:50]) or "(no matches)"
@mcp.tool()
def get_class_overview(class_name: str) -> str:
"""Compact overview of a UE class/struct: description, base classes,
and flat lists of property and function names. Use get_member() for
full signatures and docs. Use get_file() if you need delegates/enums too."""
index = _load_type_index()
if class_name not in index:
return f"'{class_name}' not in type index. Try search_types('{class_name}')."
text = (_docs_root() / index[class_name]).read_text()
section = _extract_class_section(text, class_name)
if not section:
return f"Section for '{class_name}' not found in {index[class_name]}."
return _build_overview(section)
@mcp.tool()
def get_member(class_name: str, member_name: str) -> str:
"""Full documentation entry for one function or property in a UE class.
For overloaded functions, returns all overloads together."""
index = _load_type_index()
if class_name not in index:
return f"'{class_name}' not found."
text = (_docs_root() / index[class_name]).read_text()
section = _extract_class_section(text, class_name)
if not section:
return f"'{class_name}' section missing."
entry = _extract_member(section, member_name)
return entry or f"'{member_name}' not found in {class_name}."
@mcp.tool()
def get_file(relative_path: str) -> str:
"""Full content of a documentation .md file.
relative_path is relative to $UE_DOCS_PATH, as returned by search_types().
Use this when you need delegates, top-level enums, or multiple classes from one file."""
path = _docs_root() / relative_path
if not path.exists():
return f"File not found: {relative_path}"
return path.read_text()
@mcp.tool()
def search_source(pattern: str, path_hint: str = "") -> str:
"""Grep UE source .h files for pattern. Requires $UE_ENGINE_ROOT.
path_hint: optional subdirectory under Engine/Source/ (e.g. 'Runtime/Engine').
Returns up to 40 matching lines with file:line context."""
root = _engine_root() / "Engine" / "Source"
if path_hint:
root = root / path_hint
result = subprocess.run(
["grep", "-rn", "--include=*.h", "-m", "40", pattern, str(root)],
capture_output=True, text=True, timeout=15
)
return result.stdout or "(no matches)"
if __name__ == "__main__":
mcp.run()

1183
docgen/ue_parser.py Normal file

File diff suppressed because it is too large Load Diff