Move ue_mcp_server.py back to repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
213
ue_mcp_server.py
Normal file
213
ue_mcp_server.py
Normal 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()
|
||||
Reference in New Issue
Block a user