Files
mcp-ue/ue_mcp_server.py
Pierre-Marie Charavel 77c972977a Fix search_source: pass env vars via mcp.json interpolation, search Plugins/, cap at 40 total lines
- .mcp.json: pass UE_ENGINE_ROOT and UE_DOCS_PATH via ${VAR} interpolation so the
  MCP server subprocess inherits them without hardcoding paths
- search_source: search both Engine/Source/ and Engine/Plugins/ (path_hint applied
  under both); switch from -m 40 (per-file cap) to streaming Popen with a true
  40-line total cap; fix UE_ENGINE_ROOT to point at Engine/ not the repo root
- Update README and CLAUDE.md to reflect the above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:11:46 -05:00

234 lines
7.8 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)
_type_index_cache: dict[str, str] = {}
_type_index_mtime: float = 0.0
def _load_type_index() -> dict[str, str]:
"""Returns {TypeName: relative_md_path}, reloading only if the file has changed."""
global _type_index_cache, _type_index_mtime
idx_path = _docs_root() / "type-index.txt"
mtime = idx_path.stat().st_mtime
if mtime != _type_index_mtime:
result = {}
for line in idx_path.read_text().splitlines():
if ": " in line:
name, path = line.split(": ", 1)
result[name.strip()] = path.strip()
_type_index_cache = result
_type_index_mtime = mtime
return _type_index_cache
# ---------------------------------------------------------------------------
# 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').
Searches both Engine/Source/ and Engine/Plugins/.
Returns up to 40 matching lines with file:line context."""
engine = _engine_root()
candidates = [engine / "Source", engine / "Plugins"]
search_dirs = [d / path_hint if path_hint else d for d in candidates]
search_dirs = [d for d in search_dirs if d.exists()]
if not search_dirs:
return "(no matches)"
grep = subprocess.Popen(
["grep", "-rn", "--include=*.h", pattern, *map(str, search_dirs)],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True
)
lines = []
for line in grep.stdout:
lines.append(line)
if len(lines) == 40:
grep.kill()
break
grep.wait()
return "".join(lines) or "(no matches)"
if __name__ == "__main__":
mcp.run()