diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 2ebf9022b..54faaa98c 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -413,8 +413,6 @@ jobs: "mcp-for-unity", "--transport", "stdio", - "--status-dir", - f"{workspace}/.unity-mcp", ], "transport": {"type": "stdio"}, "env": { @@ -445,8 +443,8 @@ jobs: mkdir -p "$(dirname "$LOG")" echo "" >> "$LOG" echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" - exec >> "$LOG" 2>&1 - exec {uv_cmd} "$@" + # Redirect stderr to log, keep stdout for MCP communication + exec {uv_cmd} "$@" 2>> "$LOG" """) runner_script.write_text(script) runner_script.chmod(0o755) @@ -646,7 +644,8 @@ jobs: settings: .claude/settings.json allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-sonnet-4-5-20250929 + model: claude-haiku-4-5-20251001 + fallback_model: claude-sonnet-4-5-20250929 append_system_prompt: | You are running the NL pass only. - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. @@ -817,10 +816,10 @@ jobs: def id_from_filename(p: Path): n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) if m: return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) if m: return f"T-{m.group(1).upper()}" return None @@ -864,10 +863,10 @@ jobs: seen = set() def id_from_filename(p: Path): n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) if m: return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) if m: return f"T-{m.group(1).upper()}" return None diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index d61f894b9..344b4df97 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -9,6 +9,7 @@ on: jobs: j1: + if: github.repository == 'CoplayDev/unity-mcp' name: github-repo-stats runs-on: ubuntu-latest steps: diff --git a/Server/src/services/tools/find_in_file.py b/Server/src/services/tools/find_in_file.py new file mode 100644 index 000000000..4f9254d11 --- /dev/null +++ b/Server/src/services/tools/find_in_file.py @@ -0,0 +1,174 @@ +import base64 +import os +import re +from typing import Annotated, Any +from urllib.parse import unquote, urlparse + +from fastmcp import Context + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +def _split_uri(uri: str) -> tuple[str, str]: + """Split an incoming URI or path into (name, directory) suitable for Unity. + + Rules: + - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - file://... → percent-decode, normalize, strip host and leading slashes, + then, if any 'Assets' segment exists, return path relative to that 'Assets' root. + Otherwise, fall back to original name/dir behavior. + - plain paths → decode/normalize separators; if they contain an 'Assets' segment, + return relative to 'Assets'. + """ + raw_path: str + if uri.startswith("unity://path/"): + raw_path = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + host = (parsed.netloc or "").strip() + p = parsed.path or "" + # UNC: file://server/share/... -> //server/share/... + if host and host.lower() != "localhost": + p = f"//{host}{p}" + # Use percent-decoded path, preserving leading slashes + raw_path = unquote(p) + else: + raw_path = uri + + # Percent-decode any residual encodings and normalize separators + raw_path = unquote(raw_path).replace("\\", "/") + # Strip leading slash only for Windows drive-letter forms like "/C:/..." + if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] + + # Normalize path (collapse ../, ./) + norm = os.path.normpath(raw_path).replace("\\", "/") + + # If an 'Assets' segment exists, compute path relative to it (case-insensitive) + parts = [p for p in norm.split("/") if p not in ("", ".")] + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) + assets_rel = "/".join(parts[idx:]) if idx is not None else None + + effective_path = assets_rel if assets_rel else norm + # For POSIX absolute paths outside Assets, drop the leading '/' + # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] + + name = os.path.splitext(os.path.basename(effective_path))[0] + directory = os.path.dirname(effective_path) + return name, directory + + +@mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") +async def find_in_file( + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + project_root: Annotated[str | None, "Optional project root path"] = None, + max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200, + ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True, +) -> dict[str, Any]: + # project_root is currently unused but kept for interface consistency + unity_instance = get_unity_instance_from_context(ctx) + await ctx.info( + f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})") + + name, directory = _split_uri(uri) + + # 1. Read file content via Unity + read_resp = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "manage_script", + { + "action": "read", + "name": name, + "path": directory, + }, + ) + + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data", {}) + contents = data.get("contents") + if not contents and data.get("contentsEncoded") and data.get("encodedContents"): + try: + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") + except (ValueError, TypeError, base64.binascii.Error): + contents = contents or "" + + if contents is None: + return {"success": False, "message": "Could not read file content."} + + # 2. Perform regex search + flags = re.MULTILINE + # Handle ignore_case which can be boolean or string from some clients + ic = ignore_case + if isinstance(ic, str): + ic = ic.lower() in ("true", "1", "yes") + if ic: + flags |= re.IGNORECASE + + try: + regex = re.compile(pattern, flags) + except re.error as e: + return {"success": False, "message": f"Invalid regex pattern: {e}"} + + # If the regex is not multiline specific (doesn't contain \n literal match logic), + # we could iterate lines. But users might use multiline regexes. + # Let's search the whole content and map back to lines. + + found = list(regex.finditer(contents)) + + results = [] + count = 0 + + for m in found: + if count >= max_results: + break + + start_idx = m.start() + end_idx = m.end() + + # Calculate line number + # Count newlines up to start_idx + line_num = contents.count('\n', 0, start_idx) + 1 + + # Get line content for excerpt + # Find start of line + line_start = contents.rfind('\n', 0, start_idx) + 1 + # Find end of line + line_end = contents.find('\n', start_idx) + if line_end == -1: + line_end = len(contents) + + line_content = contents[line_start:line_end] + + # Create excerpt + # We can just return the line content as excerpt + + results.append({ + "line": line_num, + "content": line_content.strip(), # detailed match info? + "match": m.group(0), + "start": start_idx, + "end": end_idx + }) + count += 1 + + return { + "success": True, + "data": { + "matches": results, + "count": len(results), + "total_matches": len(found) + } + } + diff --git a/Server/src/services/tools/manage_script.py b/Server/src/services/tools/manage_script.py index 41148682b..8cbc1e9ea 100644 --- a/Server/src/services/tools/manage_script.py +++ b/Server/src/services/tools/manage_script.py @@ -121,7 +121,7 @@ def _needs_normalization(arr: list[dict[str, Any]]) -> bool: return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} data = read_resp.get("data", {}) contents = data.get("contents") - if not contents and data.get("contentsEncoded"): + if not contents and data.get("contentsEncoded") and data.get("encodedContents"): try: contents = base64.b64decode(data.get("encodedContents", "").encode( "utf-8")).decode("utf-8", "replace") diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta b/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta deleted file mode 100644 index 65220d07a..000000000 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: d6cd845e48d9e4d558d50f7a50149682 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: