diff --git a/.gitignore b/.gitignore index 5aab8ccc..d2db1ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ GPF-output/** __pycache__/ .clangd-query.pid +.clangd-query/ diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h index 6460210a..7b4b92b0 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h @@ -1,6 +1,7 @@ #pragma once #include "CoreMinimal.h" +#include "UObject/Interface.h" #include "UObject/Object.h" #include "Dom/JsonObject.h" #include "MCPHandler.generated.h" diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h index 3baada8a..1a1fdbf6 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h @@ -1,6 +1,7 @@ #pragma once #include "CoreMinimal.h" +#include "Async/Future.h" #include "Dom/JsonObject.h" #include "MCPUtils.h" class FSocket; diff --git a/build.py b/build.py index 38d0f016..1944d740 100755 --- a/build.py +++ b/build.py @@ -323,6 +323,7 @@ def build_compile_commands_from_integration(): if len(clangs) != 1: sys.exit("Couldn't identify correct clang++ compiler in UnrealEngine thirdparty directory") clang = str(clangs[0]) # Build the table of source files and RSP files. + # First, scan unity Module.*.o.rsp files and expand their #included .cpp files. mods1 = Path(f"{INTEGRATION}/Intermediate/Build/{OS}").rglob(f"UnrealEditor/{DEBUG}/**/Module.*.o.rsp") mods1p = Path(f"{INTEGRATION}/Plugins").rglob(f"Intermediate/Build/{OS}/x64/UnrealEditor/{DEBUG}/**/Module.*.o.rsp") mods2 = Path(f"{UNREALENGINE}/Engine/Intermediate/Build/{OS}").rglob("UnrealEditor/Development/**/Module.*.o.rsp") @@ -334,6 +335,15 @@ def build_compile_commands_from_integration(): for subfile in cpp_files_included_by(cpp): abs = os.path.abspath(os.path.join(f"{UNREALENGINE}/Engine/Source", subfile)) cpp_to_rsp[abs] = rsp + # Also pick up non-unity .cpp files that have their own .o.rsp (e.g. files + # excluded from unity builds). Only include if the .cpp file actually exists. + standalone1 = Path(f"{INTEGRATION}/Intermediate/Build/{OS}").rglob(f"UnrealEditor/{DEBUG}/**/*.cpp.o.rsp") + standalone1p = Path(f"{INTEGRATION}/Plugins").rglob(f"Intermediate/Build/{OS}/x64/UnrealEditor/{DEBUG}/**/*.cpp.o.rsp") + for rsp_path in itertools.chain(standalone1, standalone1p): + rsp = str(rsp_path) + cpp = os.path.abspath(rsp.removesuffix(".o.rsp")) + if cpp not in cpp_to_rsp and os.path.exists(cpp): + cpp_to_rsp[cpp] = rsp # Generate compile commands. entries = [] ccdir = f"{UNREALENGINE}/Engine/Source" diff --git a/tools/clangd-query.py b/tools/clangd-query.py index d5b19bd6..c1f02829 100755 --- a/tools/clangd-query.py +++ b/tools/clangd-query.py @@ -149,6 +149,22 @@ class ClangdDaemon: def __init__(self): clangd_path, clangd_args = read_workspace_config() + + # Give the daemon its own compile-commands dir so it gets a separate + # clangd index cache (clangd stores .cache/ next to compile_commands.json). + daemon_cc_dir = os.path.join(PROJECT_DIR, ".clangd-query") + os.makedirs(daemon_cc_dir, exist_ok=True) + cc_link = os.path.join(daemon_cc_dir, "compile_commands.json") + cc_src = os.path.join(PROJECT_DIR, ".vscode", "compile_commands.json") + if os.path.islink(cc_link) or os.path.exists(cc_link): + os.unlink(cc_link) + os.symlink(cc_src, cc_link) + + # Replace --compile-commands-dir in args to point to our dir + clangd_args = [ + f"--compile-commands-dir={daemon_cc_dir}" if a.startswith("--compile-commands-dir") else a + for a in clangd_args + ] cmd = [clangd_path] + clangd_args + ["-j=4"] self.proc = subprocess.Popen( @@ -164,6 +180,8 @@ class ClangdDaemon: self.events = {} self.index_loaded = threading.Event() self.opened_files = set() + self.diagnostics = {} # uri -> list of diagnostics + self.diag_events = {} # uri -> threading.Event threading.Thread(target=self._stdout_reader, daemon=True).start() threading.Thread(target=self._stderr_reader, daemon=True).start() @@ -173,6 +191,14 @@ class ClangdDaemon: msg = read_lsp_message(self.proc.stdout) if msg is None: break + # Handle server-initiated diagnostics notifications + if msg.get("method") == "textDocument/publishDiagnostics": + uri = msg["params"]["uri"] + with self.lock: + self.diagnostics[uri] = msg["params"]["diagnostics"] + if uri in self.diag_events: + self.diag_events[uri].set() + continue msg_id = msg.get("id") if msg_id is not None and msg_id in self.pending: with self.lock: @@ -241,6 +267,20 @@ class ClangdDaemon: resp = self.send_request("workspace/symbol", {"query": args[0]}) return resp + if cmd == "diagnostics": + filepath = args[0] + abs_path = os.path.abspath(filepath) + uri = f"file://{abs_path}" + # Set up event before opening so we don't miss the notification + with self.lock: + self.diag_events[uri] = threading.Event() + self.open_file(abs_path) + self.diag_events[uri].wait(timeout=30) + with self.lock: + diags = self.diagnostics.get(uri, []) + self.diag_events.pop(uri, None) + return {"result": diags} + if cmd == "definition": filepath, line, col = args[0], int(args[1]), int(args[2]) abs_path = os.path.abspath(filepath) @@ -408,15 +448,27 @@ def start_daemon(): sys.exit(1) -def send_query(query): +def send_query(query, retries=3): """Send a query to the daemon and return the response.""" - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(60) - sock.connect(SOCKET_PATH) - sock_send(sock, query) - result = sock_recv(sock) - sock.close() - return result + for attempt in range(retries): + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(60) + sock.connect(SOCKET_PATH) + sock_send(sock, query) + result = sock_recv(sock) + sock.close() + return result + except (ConnectionResetError, ConnectionRefusedError, BrokenPipeError, OSError): + sock.close() + if attempt < retries - 1: + import time + time.sleep(2) + # Daemon may have died — restart it + if not daemon_is_alive(): + start_daemon() + else: + raise def ensure_daemon(): @@ -444,6 +496,22 @@ def format_symbol_results(resp): print(f"{path}:{line} [{kind}] {qualified}") +def format_diagnostics(resp): + if not resp or "result" not in resp: + print("No diagnostics (or clangd error).", file=sys.stderr) + return + diags = resp["result"] + if not diags: + print("No problems found.") + return + for d in diags: + line = d.get("range", {}).get("start", {}).get("line", 0) + 1 + col = d.get("range", {}).get("start", {}).get("character", 0) + 1 + severity = {1: "error", 2: "warning", 3: "info", 4: "hint"}.get(d.get("severity", 0), "unknown") + msg = d.get("message", "") + print(f" {line}:{col} [{severity}] {msg}") + + def format_location_results(resp, label="results"): if not resp or "result" not in resp: print(f"No {label} (or clangd error).", file=sys.stderr) @@ -485,6 +553,10 @@ def main(): if len(args) < 1: print("Usage: clangd-query.py symbol ", file=sys.stderr) sys.exit(1) + elif command == "diagnostics": + if len(args) < 1: + print("Usage: clangd-query.py diagnostics ", file=sys.stderr) + sys.exit(1) elif command in ("definition", "references"): if len(args) < 3: print(f"Usage: clangd-query.py {command} ", @@ -500,6 +572,8 @@ def main(): if command == "symbol": format_symbol_results(resp) + elif command == "diagnostics": + format_diagnostics(resp) elif command == "definition": format_location_results(resp, "definition") elif command == "references":