214 lines
7.1 KiB
Python
214 lines
7.1 KiB
Python
"""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()
|