feat: Automatic Background Compile and Domain Reload for MCP Script Edits and New Script Creation (#248)

* Unity MCP: reliable auto-reload via Unity-side sentinel flip; remove Python writes

- Add MCP/Flip Reload Sentinel editor menu and flip package sentinel synchronously
- Trigger sentinel flip after Create/Update/ApplyTextEdits (sync) in ManageScript
- Instrument flip path with Debug.Log for traceability
- Remove Python reload_sentinel writes; tools now execute Unity menu instead
- Harden reload_sentinel path resolution to project/package
- ExecuteMenuItem runs synchronously for deterministic results
- Verified MCP edits trigger compile/reload without focus; no Python permission errors

* Getting double flips

* Fix double reload and ensure accurate batch edits; immediate structured reloads

* Remove MCP/Flip Reload Sentinel menu; rely on synchronous import/compile for reloads

* Route bridge/editor logs through McpLog and gate behind debug; create path now reloads synchronously

* chore: ignore backup artifacts; remove stray ManageScript.cs.backup files

* fix span logic

* fix: honor UNITY_MCP_STATUS_DIR for sentinel status file lookup (fallback to ~/.unity-mcp)

* test: add sentinel test honoring UNITY_MCP_STATUS_DIR; chore: ManageScript overlap check simplification and log consistency

* Harden environment path, remove extraneous flip menu test

* refactor: centralize import/compile via ManageScriptRefreshHelpers.ImportAndRequestCompile; replace duplicated sequences

* feat: add scheduledRefresh flag; standardize logs; gate info and DRY immediate import/compile

* chore: remove execute_menu_item sentinel flip from manage_script_edits; rely on import/compile

* chore: remove unused MCP reload sentinel mechanism

* fix: honor ignore_case for anchor_insert in text conversion path
main
dsarno 2025-09-02 09:36:50 -07:00 committed by GitHub
parent ad848f06df
commit 0c52c1c92e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 218 additions and 95 deletions

4
.gitignore vendored
View File

@ -36,3 +36,7 @@ CONTRIBUTING.md.meta
.DS_Store*
# Unity test project lock files
TestProjects/UnityMCPTests/Packages/packages-lock.json
# Backup artifacts
*.backup
*.backup.meta

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 46421b2ea84fe4b1a903e2483cff3958
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,11 +1,15 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Collections;
public class Hello : MonoBehaviour
{
// Use this for initialization
void Start()
{
Debug.Log("Hello World");
}
}
}

View File

@ -1,11 +1,2 @@
fileFormatVersion: 2
guid: bebdf68a6876b425494ee770d20f70ef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
guid: bebdf68a6876b425494ee770d20f70ef

View File

@ -48,7 +48,7 @@ namespace MCPForUnity.Editor
{
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: [{stage}]");
McpLog.Info($"[{stage}]", always: false);
}
}
@ -230,7 +230,10 @@ namespace MCPForUnity.Editor
// Don't restart if already running on a working port
if (isRunning && listener != null)
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
}
return;
}
@ -348,7 +351,7 @@ namespace MCPForUnity.Editor
listener?.Stop();
listener = null;
EditorApplication.update -= ProcessCommands;
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
}
catch (Exception ex)
{
@ -389,7 +392,7 @@ namespace MCPForUnity.Editor
{
if (isRunning)
{
Debug.LogError($"Listener error: {ex.Message}");
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
}
}
}
@ -403,8 +406,11 @@ namespace MCPForUnity.Editor
// Framed I/O only; legacy mode removed
try
{
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
if (IsDebugEnabled())
{
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
}
}
catch { }
// Strict framing: always require FRAMING=1 and frame all I/O
@ -423,11 +429,11 @@ namespace MCPForUnity.Editor
#else
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
#endif
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: Sent handshake FRAMING=1 (strict)");
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
}
catch (Exception ex)
{
Debug.LogWarning($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Handshake failed: {ex.Message}");
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
return; // abort this client
}
@ -440,8 +446,11 @@ namespace MCPForUnity.Editor
try
{
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: recv framed: {preview}");
if (IsDebugEnabled())
{
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false);
}
}
catch { }
string commandId = Guid.NewGuid().ToString();
@ -470,7 +479,20 @@ namespace MCPForUnity.Editor
}
catch (Exception ex)
{
Debug.LogError($"Client handler error: {ex.Message}");
// Treat common disconnects/timeouts as benign; only surface hard errors
string msg = ex.Message ?? string.Empty;
bool isBenign =
msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
|| ex is System.IO.IOException;
if (isBenign)
{
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false);
}
else
{
MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}");
}
break;
}
}

View File

@ -68,6 +68,8 @@ namespace MCPForUnity.Editor.Tools
{
// Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
// Optional future param retained for API compatibility; not used in synchronous mode
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject<int>() ?? 2000));
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
@ -94,42 +96,29 @@ namespace MCPForUnity.Editor.Tools
try
{
// Attempt to execute the menu item on the main thread using delayCall for safety.
EditorApplication.delayCall += () =>
{
try
{
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
// Log potential failure inside the delayed call.
if (!executed)
{
Debug.LogError(
$"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."
);
}
}
catch (Exception delayEx)
{
Debug.LogError(
$"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}"
);
}
};
// Trace incoming execute requests
Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
// Report attempt immediately, as execution is delayed.
return Response.Success(
$"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (executed)
{
Debug.Log($"[ExecuteMenuItem] Executed successfully: '{menuPath}'");
return Response.Success(
$"Executed menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
return Response.Error(
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
new { executed = false, menuPath }
);
}
catch (Exception e)
{
// Catch errors during setup phase.
Debug.LogError(
$"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}"
);
return Response.Error(
$"Error setting up execution for menu item '{menuPath}': {e.Message}"
);
Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
}
}

View File

@ -193,10 +193,10 @@ namespace MCPForUnity.Editor.Tools
namespaceName
);
case "read":
Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility.");
McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility.");
return ReadScript(fullPath, relativePath);
case "update":
Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility.");
McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility.");
return UpdateScript(fullPath, relativePath, name, contents);
case "delete":
return DeleteScript(fullPath, relativePath);
@ -356,11 +356,11 @@ namespace MCPForUnity.Editor.Tools
var uri = $"unity://path/{relativePath}";
var ok = Response.Success(
$"Script '{name}.cs' created successfully at '{relativePath}'.",
new { uri, scheduledRefresh = true }
new { uri, scheduledRefresh = false }
);
// Schedule heavy work AFTER replying
ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath);
ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath);
return ok;
}
catch (Exception e)
@ -650,7 +650,7 @@ namespace MCPForUnity.Editor.Tools
spans = spans.OrderByDescending(t => t.start).ToList();
for (int i = 1; i < spans.Count; i++)
{
if (spans[i].end > spans[i - 1].start)
if (spans[i].end > spans[i - 1].start)
{
var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } };
return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." });
@ -763,19 +763,18 @@ namespace MCPForUnity.Editor.Tools
string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase);
if (immediate)
{
EditorApplication.delayCall += () =>
{
AssetDatabase.ImportAsset(
relativePath,
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
);
McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'");
AssetDatabase.ImportAsset(
relativePath,
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
);
#if UNITY_EDITOR
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
#endif
};
}
else
{
McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'");
ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath);
}
@ -786,7 +785,8 @@ namespace MCPForUnity.Editor.Tools
uri = $"unity://path/{relativePath}",
path = relativePath,
editsApplied = spans.Count,
sha256 = newSha
sha256 = newSha,
scheduledRefresh = !immediate
}
);
}
@ -1326,7 +1326,7 @@ namespace MCPForUnity.Editor.Tools
if (ordered[i].start + ordered[i].length > ordered[i - 1].start)
{
var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } };
return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Apply in descending order against the same precondition snapshot." });
return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." });
}
}
return Response.Error("overlap", new { status = "overlap" });
@ -1421,17 +1421,8 @@ namespace MCPForUnity.Editor.Tools
if (immediate)
{
// Force on main thread
EditorApplication.delayCall += () =>
{
AssetDatabase.ImportAsset(
relativePath,
ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate
);
#if UNITY_EDITOR
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
#endif
};
McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false);
ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath);
}
else
{
@ -2620,5 +2611,15 @@ static class ManageScriptRefreshHelpers
{
RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200));
}
public static void ImportAndRequestCompile(string relPath, bool synchronous = true)
{
var opts = ImportAssetOptions.ForceUpdate;
if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport;
AssetDatabase.ImportAsset(relPath, opts);
#if UNITY_EDITOR
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
#endif
}
}

View File

@ -1771,7 +1771,7 @@ namespace MCPForUnity.Editor.Windows
{
if (debugLogsEnabled)
{
UnityEngine.Debug.Log($"MCP for Unity: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}");
MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false);
}
mcpClient.SetStatus(McpStatus.Configured);
}
@ -1971,7 +1971,7 @@ namespace MCPForUnity.Editor.Windows
if (debugLogsEnabled)
{
UnityEngine.Debug.Log($"Checking Claude config at: {configPath}");
MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false);
}
if (!File.Exists(configPath))

View File

@ -0,0 +1,8 @@
"""
Deprecated: Sentinel flipping is handled inside Unity via the MCP menu
'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim.
All functions are no-ops to prevent accidental external writes.
"""
def flip_reload_sentinel(*args, **kwargs) -> str:
return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'"

View File

@ -306,6 +306,38 @@ def register_manage_script_tools(mcp: FastMCP):
data.setdefault("normalizedEdits", normalized_edits)
if warnings:
data.setdefault("warnings", warnings)
if resp.get("success") and (options or {}).get("force_sentinel_reload"):
# Optional: flip sentinel via menu if explicitly requested
try:
import threading, time, json, glob, os
def _latest_status() -> dict | None:
try:
files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
if not files:
return None
with open(files[0], "r") as f:
return json.loads(f.read())
except Exception:
return None
def _flip_async():
try:
time.sleep(0.1)
st = _latest_status()
if st and st.get("reloading"):
return
send_command_with_retry(
"execute_menu_item",
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
retry_ms=0,
)
except Exception:
pass
threading.Thread(target=_flip_async, daemon=True).start()
except Exception:
pass
return resp
return resp
return {"success": False, "message": str(resp)}
@ -457,7 +489,7 @@ def register_manage_script_tools(mcp: FastMCP):
"path": path,
"edits": edits,
"precondition_sha256": sha,
"options": {"refresh": "immediate", "validate": "standard"},
"options": {"refresh": "debounced", "validate": "standard"},
}
# Preflight size vs. default cap (256 KiB) to avoid opaque server errors
try:

View File

@ -2,6 +2,7 @@ from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List, Tuple
import base64
import re
import os
from unity_connection import send_command_with_retry
@ -80,6 +81,37 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
return text
def _trigger_sentinel_async() -> None:
"""Fire the Unity menu flip on a short-lived background thread.
This avoids blocking the current request or getting stuck during domain reloads
(socket reconnects) when the Editor recompiles.
"""
try:
import threading, time
def _flip():
try:
import json, glob, os
# Small delay so write flushes; prefer early flip to avoid editor-focus second reload
time.sleep(0.1)
try:
files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
if files:
with open(files[0], "r") as f:
st = json.loads(f.read())
if st.get("reloading"):
return
except Exception:
pass
except Exception:
pass
threading.Thread(target=_flip, daemon=True).start()
except Exception:
pass
def _infer_class_name(script_name: str) -> str:
# Default to script name as class name (common Unity pattern)
return (script_name or "").strip()
@ -470,7 +502,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
# If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
if all_struct:
opts2 = dict(options or {})
# Do not force sequential; allow server default (atomic) unless caller requests otherwise
# For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
opts2.setdefault("refresh", "immediate")
params_struct: Dict[str, Any] = {
"action": "edit",
@ -482,6 +514,13 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"options": opts2,
}
resp_struct = send_command_with_retry("manage_script", params_struct)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
# Optional: flip sentinel only if explicitly requested
if (options or {}).get("force_sentinel_reload"):
try:
_trigger_sentinel_async()
except Exception:
pass
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
# 1) read from Unity
@ -597,18 +636,24 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"scriptType": script_type,
"edits": at_edits,
"precondition_sha256": sha,
"options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
"options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
}
resp_text = send_command_with_retry("manage_script", params_text)
if not (isinstance(resp_text, dict) and resp_text.get("success")):
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
# Successful text write; flip sentinel only if explicitly requested
if (options or {}).get("force_sentinel_reload"):
try:
_trigger_sentinel_async()
except Exception:
pass
except Exception as e:
return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
if struct_edits:
opts2 = dict(options or {})
# Let server decide; do not force sequential
opts2.setdefault("refresh", "immediate")
# Prefer debounced background refresh unless explicitly overridden
opts2.setdefault("refresh", "debounced")
params_struct: Dict[str, Any] = {
"action": "edit",
"name": name,
@ -619,6 +664,12 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"options": opts2
}
resp_struct = send_command_with_retry("manage_script", params_struct)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
if (options or {}).get("force_sentinel_reload"):
try:
_trigger_sentinel_async()
except Exception:
pass
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
@ -648,9 +699,10 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if op == "anchor_insert":
anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower()
# Early regex compile with helpful errors
# Early regex compile with helpful errors, honoring ignore_case
try:
regex_obj = _re.compile(anchor, _re.MULTILINE)
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
regex_obj = _re.compile(anchor, flags)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
m = regex_obj.search(base_text)
@ -734,12 +786,18 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"edits": at_edits,
"precondition_sha256": sha,
"options": {
"refresh": "immediate",
"refresh": (options or {}).get("refresh", "debounced"),
"validate": (options or {}).get("validate", "standard"),
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
}
}
resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
if (options or {}).get("force_sentinel_reload"):
try:
_trigger_sentinel_async()
except Exception:
pass
return _with_norm(
resp if isinstance(resp, dict) else {"success": False, "message": str(resp)},
normalized_for_echo,
@ -791,7 +849,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
# Default refresh/validate for natural usage on text path as well
options = dict(options or {})
options.setdefault("validate", "standard")
options.setdefault("refresh", "immediate")
options.setdefault("refresh", "debounced")
import hashlib
# Compute the SHA of the current file contents for the precondition
@ -816,10 +874,16 @@ def register_manage_script_edits_tools(mcp: FastMCP):
}
],
"precondition_sha256": sha,
"options": options or {"validate": "standard", "refresh": "immediate"},
"options": options or {"validate": "standard", "refresh": "debounced"},
}
write_resp = send_command_with_retry("manage_script", params)
if isinstance(write_resp, dict) and write_resp.get("success"):
if (options or {}).get("force_sentinel_reload"):
try:
_trigger_sentinel_async()
except Exception:
pass
return _with_norm(
write_resp if isinstance(write_resp, dict)
else {"success": False, "message": str(write_resp)},

View File

@ -162,7 +162,7 @@ cli = [
[[package]]
name = "mcpforunityserver"
version = "3.0.2"
version = "3.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },