Autoformat (#297)

main
Marcus Sanatan 2025-09-30 16:25:33 -04:00 committed by GitHub
parent 47ec46ce93
commit f6796e61f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 2393 additions and 2246 deletions

View File

@ -29,6 +29,7 @@ PATTERNS = [
r"validation error .* ctx", r"validation error .* ctx",
] ]
def should_skip(msg: str) -> bool: def should_skip(msg: str) -> bool:
if not msg: if not msg:
return False return False
@ -38,6 +39,7 @@ def should_skip(msg: str) -> bool:
return True return True
return False return False
def summarize_counts(ts: ET.Element): def summarize_counts(ts: ET.Element):
tests = 0 tests = 0
failures = 0 failures = 0
@ -53,6 +55,7 @@ def summarize_counts(ts: ET.Element):
skipped += 1 skipped += 1
return tests, failures, errors, skipped return tests, failures, errors, skipped
def main(path: str) -> int: def main(path: str) -> int:
if not os.path.exists(path): if not os.path.exists(path):
print(f"[mark_skipped] No JUnit at {path}; nothing to do.") print(f"[mark_skipped] No JUnit at {path}; nothing to do.")
@ -79,7 +82,8 @@ def main(path: str) -> int:
for n in nodes: for n in nodes:
msg = (n.get("message") or "") + "\n" + (n.text or "") msg = (n.get("message") or "") + "\n" + (n.text or "")
if should_skip(msg): if should_skip(msg):
first_match_text = (n.text or "").strip() or first_match_text first_match_text = (
n.text or "").strip() or first_match_text
to_skip = True to_skip = True
if to_skip: if to_skip:
for n in nodes: for n in nodes:
@ -98,12 +102,14 @@ def main(path: str) -> int:
if changed: if changed:
tree.write(path, encoding="utf-8", xml_declaration=True) tree.write(path, encoding="utf-8", xml_declaration=True)
print(f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") print(
f"[mark_skipped] Updated {path}: converted environmental failures to skipped.")
else: else:
print(f"[mark_skipped] No environmental failures detected in {path}.") print(f"[mark_skipped] No environmental failures detected in {path}.")
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
target = ( target = (
sys.argv[1] sys.argv[1]

View File

@ -12,7 +12,7 @@ on:
- major - major
default: patch default: patch
required: true required: true
jobs: jobs:
bump: bump:
name: "Bump version and tag" name: "Bump version and tag"

File diff suppressed because it is too large Load Diff

View File

@ -17,4 +17,3 @@ jobs:
uses: jgehrcke/github-repo-stats@RELEASE uses: jgehrcke/github-repo-stats@RELEASE
with: with:
ghtoken: ${{ secrets.ghrs_github_api_token }} ghtoken: ${{ secrets.ghrs_github_api_token }}

View File

@ -2,7 +2,7 @@ name: Unity Tests
on: on:
push: push:
branches: [ main ] branches: [main]
paths: paths:
- TestProjects/UnityMCPTests/** - TestProjects/UnityMCPTests/**
- UnityMcpBridge/Editor/** - UnityMcpBridge/Editor/**

View File

@ -3,13 +3,8 @@ using System.Collections;
public class Hello : MonoBehaviour public class Hello : MonoBehaviour
{ {
// Use this for initialization
void Start() void Start()
{ {
Debug.Log("Hello World"); Debug.Log("Hello World");
} }
} }

View File

@ -2035,5 +2035,3 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
#endregion #endregion
} }

View File

@ -6,7 +6,7 @@ namespace TestNamespace
{ {
[SerializeField] [SerializeField]
private string customText = "Hello from custom asmdef!"; private string customText = "Hello from custom asmdef!";
[SerializeField] [SerializeField]
private float customFloat = 42.0f; private float customFloat = 42.0f;
@ -15,4 +15,4 @@ namespace TestNamespace
Debug.Log($"CustomComponent started: {customText}, value: {customFloat}"); Debug.Log($"CustomComponent started: {customText}, value: {customFloat}");
} }
} }
} }

View File

@ -17,7 +17,7 @@ namespace MCPForUnityTests.Editor.Tools
sampleProperties = new List<string> sampleProperties = new List<string>
{ {
"maxReachDistance", "maxReachDistance",
"maxHorizontalDistance", "maxHorizontalDistance",
"maxVerticalDistance", "maxVerticalDistance",
"moveSpeed", "moveSpeed",
"healthPoints", "healthPoints",
@ -33,7 +33,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAllComponentProperties_ReturnsValidProperties_ForTransform() public void GetAllComponentProperties_ReturnsValidProperties_ForTransform()
{ {
var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform));
Assert.IsNotEmpty(properties, "Transform should have properties"); Assert.IsNotEmpty(properties, "Transform should have properties");
Assert.Contains("position", properties, "Transform should have position property"); Assert.Contains("position", properties, "Transform should have position property");
Assert.Contains("rotation", properties, "Transform should have rotation property"); Assert.Contains("rotation", properties, "Transform should have rotation property");
@ -44,7 +44,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAllComponentProperties_ReturnsEmpty_ForNullType() public void GetAllComponentProperties_ReturnsEmpty_ForNullType()
{ {
var properties = ComponentResolver.GetAllComponentProperties(null); var properties = ComponentResolver.GetAllComponentProperties(null);
Assert.IsEmpty(properties, "Null type should return empty list"); Assert.IsEmpty(properties, "Null type should return empty list");
} }
@ -52,7 +52,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput() public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties);
Assert.IsEmpty(suggestions, "Null input should return no suggestions"); Assert.IsEmpty(suggestions, "Null input should return no suggestions");
} }
@ -60,7 +60,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput() public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties);
Assert.IsEmpty(suggestions, "Empty input should return no suggestions"); Assert.IsEmpty(suggestions, "Empty input should return no suggestions");
} }
@ -68,7 +68,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList() public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List<string>()); var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List<string>());
Assert.IsEmpty(suggestions, "Empty property list should return no suggestions"); Assert.IsEmpty(suggestions, "Empty property list should return no suggestions");
} }
@ -76,7 +76,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning() public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties);
Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces"); Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces");
Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match"); Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match");
} }
@ -85,9 +85,9 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_FindsMultipleWordMatches() public void GetAIPropertySuggestions_FindsMultipleWordMatches()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties);
Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance"); Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance");
Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance"); Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance");
Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance"); Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance");
} }
@ -95,7 +95,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos() public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S
Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital"); Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital");
} }
@ -103,7 +103,7 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms() public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms()
{ {
var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties);
// Note: Current algorithm might not find "mass" but should handle it gracefully // Note: Current algorithm might not find "mass" but should handle it gracefully
Assert.IsNotNull(suggestions, "Should return valid suggestions list"); Assert.IsNotNull(suggestions, "Should return valid suggestions list");
} }
@ -113,7 +113,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// Test with input that might match many properties // Test with input that might match many properties
var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties);
Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer"); Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer");
} }
@ -121,13 +121,13 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_CachesResults() public void GetAIPropertySuggestions_CachesResults()
{ {
var input = "Max Reach Distance"; var input = "Max Reach Distance";
// First call // First call
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
// Second call should use cache (tested indirectly by ensuring consistency) // Second call should use cache (tested indirectly by ensuring consistency)
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent"); Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent");
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical"); CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical");
} }
@ -136,11 +136,11 @@ namespace MCPForUnityTests.Editor.Tools
public void GetAIPropertySuggestions_HandlesUnityNamingConventions() public void GetAIPropertySuggestions_HandlesUnityNamingConventions()
{ {
var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" }; var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" };
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties); var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties);
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties);
var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties); var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties);
Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention"); Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention");
Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention"); Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention");
Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention"); Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention");
@ -151,7 +151,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" }; var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" };
var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties); var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties);
Assert.IsNotEmpty(suggestions, "Should find suggestions"); Assert.IsNotEmpty(suggestions, "Should find suggestions");
Assert.Contains("speed", suggestions, "Exact match should be included in results"); Assert.Contains("speed", suggestions, "Exact match should be included in results");
// Note: Implementation may or may not prioritize exact matches first // Note: Implementation may or may not prioritize exact matches first
@ -162,7 +162,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties); var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties);
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties);
Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input"); Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input");
Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input"); Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input");
} }

View File

@ -12,7 +12,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName()
{ {
bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error);
Assert.IsTrue(result, "Should resolve Transform component"); Assert.IsTrue(result, "Should resolve Transform component");
Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type"); Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type");
Assert.IsEmpty(error, "Should have no error message"); Assert.IsEmpty(error, "Should have no error message");
@ -22,7 +22,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName()
{ {
bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error); bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error);
Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component"); Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component");
Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type"); Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type");
Assert.IsEmpty(error, "Should have no error message"); Assert.IsEmpty(error, "Should have no error message");
@ -32,7 +32,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsTrue_ForCustomComponentShortName() public void TryResolve_ReturnsTrue_ForCustomComponentShortName()
{ {
bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error);
Assert.IsTrue(result, "Should resolve CustomComponent"); Assert.IsTrue(result, "Should resolve CustomComponent");
Assert.IsNotNull(type, "Should return valid type"); Assert.IsNotNull(type, "Should return valid type");
Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name");
@ -44,7 +44,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName()
{ {
bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error); bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error);
Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent"); Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent");
Assert.IsNotNull(type, "Should return valid type"); Assert.IsNotNull(type, "Should return valid type");
Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name");
@ -57,7 +57,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsFalse_ForNonExistentComponent() public void TryResolve_ReturnsFalse_ForNonExistentComponent()
{ {
bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error); bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error);
Assert.IsFalse(result, "Should not resolve non-existent component"); Assert.IsFalse(result, "Should not resolve non-existent component");
Assert.IsNull(type, "Should return null type"); Assert.IsNull(type, "Should return null type");
Assert.IsNotEmpty(error, "Should have error message"); Assert.IsNotEmpty(error, "Should have error message");
@ -68,7 +68,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsFalse_ForEmptyString() public void TryResolve_ReturnsFalse_ForEmptyString()
{ {
bool result = ComponentResolver.TryResolve("", out Type type, out string error); bool result = ComponentResolver.TryResolve("", out Type type, out string error);
Assert.IsFalse(result, "Should not resolve empty string"); Assert.IsFalse(result, "Should not resolve empty string");
Assert.IsNull(type, "Should return null type"); Assert.IsNull(type, "Should return null type");
Assert.IsNotEmpty(error, "Should have error message"); Assert.IsNotEmpty(error, "Should have error message");
@ -78,7 +78,7 @@ namespace MCPForUnityTests.Editor.Tools
public void TryResolve_ReturnsFalse_ForNullString() public void TryResolve_ReturnsFalse_ForNullString()
{ {
bool result = ComponentResolver.TryResolve(null, out Type type, out string error); bool result = ComponentResolver.TryResolve(null, out Type type, out string error);
Assert.IsFalse(result, "Should not resolve null string"); Assert.IsFalse(result, "Should not resolve null string");
Assert.IsNull(type, "Should return null type"); Assert.IsNull(type, "Should return null type");
Assert.IsNotEmpty(error, "Should have error message"); Assert.IsNotEmpty(error, "Should have error message");
@ -90,10 +90,10 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// First call // First call
bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1); bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1);
// Second call should use cache // Second call should use cache
bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2); bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2);
Assert.IsTrue(result1, "First call should succeed"); Assert.IsTrue(result1, "First call should succeed");
Assert.IsTrue(result2, "Second call should succeed"); Assert.IsTrue(result2, "Second call should succeed");
Assert.AreSame(type1, type2, "Should return same type instance (cached)"); Assert.AreSame(type1, type2, "Should return same type instance (cached)");
@ -106,27 +106,27 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// Test that custom user scripts (in Player assemblies) are found // Test that custom user scripts (in Player assemblies) are found
bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error);
Assert.IsTrue(result, "Should resolve user script from Player assembly"); Assert.IsTrue(result, "Should resolve user script from Player assembly");
Assert.IsNotNull(type, "Should return valid type"); Assert.IsNotNull(type, "Should return valid type");
// Verify it's not from an Editor assembly by checking the assembly name // Verify it's not from an Editor assembly by checking the assembly name
string assemblyName = type.Assembly.GetName().Name; string assemblyName = type.Assembly.GetName().Name;
Assert.That(assemblyName, Does.Not.Contain("Editor"), Assert.That(assemblyName, Does.Not.Contain("Editor"),
"User script should come from Player assembly, not Editor assembly"); "User script should come from Player assembly, not Editor assembly");
// Verify it's from the TestAsmdef assembly (which is a Player assembly) // Verify it's from the TestAsmdef assembly (which is a Player assembly)
Assert.AreEqual("TestAsmdef", assemblyName, Assert.AreEqual("TestAsmdef", assemblyName,
"CustomComponent should be resolved from TestAsmdef assembly"); "CustomComponent should be resolved from TestAsmdef assembly");
} }
[Test] [Test]
public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() public void TryResolve_HandlesDuplicateNames_WithAmbiguityError()
{ {
// This test would need duplicate component names to be meaningful // This test would need duplicate component names to be meaningful
// For now, test with a built-in component that should not have duplicates // For now, test with a built-in component that should not have duplicates
bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error);
Assert.IsTrue(result, "Transform should resolve uniquely"); Assert.IsTrue(result, "Transform should resolve uniquely");
Assert.AreEqual(typeof(Transform), type, "Should return correct type"); Assert.AreEqual(typeof(Transform), type, "Should return correct type");
Assert.IsEmpty(error, "Should have no ambiguity error"); Assert.IsEmpty(error, "Should have no ambiguity error");
@ -136,11 +136,11 @@ namespace MCPForUnityTests.Editor.Tools
public void ResolvedType_IsValidComponent() public void ResolvedType_IsValidComponent()
{ {
bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error); bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error);
Assert.IsTrue(result, "Should resolve Rigidbody"); Assert.IsTrue(result, "Should resolve Rigidbody");
Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component"); Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component");
Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) ||
typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component"); typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component");
} }
} }
} }

View File

@ -12,7 +12,7 @@ namespace MCPForUnityTests.Editor.Tools
public class ManageGameObjectTests public class ManageGameObjectTests
{ {
private GameObject testGameObject; private GameObject testGameObject;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
@ -20,7 +20,7 @@ namespace MCPForUnityTests.Editor.Tools
testGameObject = new GameObject("TestObject"); testGameObject = new GameObject("TestObject");
} }
[TearDown] [TearDown]
public void TearDown() public void TearDown()
{ {
// Clean up test GameObject // Clean up test GameObject
@ -34,17 +34,17 @@ namespace MCPForUnityTests.Editor.Tools
public void HandleCommand_ReturnsError_ForNullParams() public void HandleCommand_ReturnsError_ForNullParams()
{ {
var result = ManageGameObject.HandleCommand(null); var result = ManageGameObject.HandleCommand(null);
Assert.IsNotNull(result, "Should return a result object"); Assert.IsNotNull(result, "Should return a result object");
// Note: Actual error checking would need access to Response structure // Note: Actual error checking would need access to Response structure
} }
[Test] [Test]
public void HandleCommand_ReturnsError_ForEmptyParams() public void HandleCommand_ReturnsError_ForEmptyParams()
{ {
var emptyParams = new JObject(); var emptyParams = new JObject();
var result = ManageGameObject.HandleCommand(emptyParams); var result = ManageGameObject.HandleCommand(emptyParams);
Assert.IsNotNull(result, "Should return a result object for empty params"); Assert.IsNotNull(result, "Should return a result object for empty params");
} }
@ -56,11 +56,11 @@ namespace MCPForUnityTests.Editor.Tools
["action"] = "create", ["action"] = "create",
["name"] = "TestCreateObject" ["name"] = "TestCreateObject"
}; };
var result = ManageGameObject.HandleCommand(createParams); var result = ManageGameObject.HandleCommand(createParams);
Assert.IsNotNull(result, "Should return a result for valid create action"); Assert.IsNotNull(result, "Should return a result for valid create action");
// Clean up - find and destroy the created object // Clean up - find and destroy the created object
var createdObject = GameObject.Find("TestCreateObject"); var createdObject = GameObject.Find("TestCreateObject");
if (createdObject != null) if (createdObject != null)
@ -74,7 +74,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// Test that our ComponentResolver works with actual Unity components // Test that our ComponentResolver works with actual Unity components
var transformResult = ComponentResolver.TryResolve("Transform", out Type transformType, out string error); var transformResult = ComponentResolver.TryResolve("Transform", out Type transformType, out string error);
Assert.IsTrue(transformResult, "Should resolve Transform component"); Assert.IsTrue(transformResult, "Should resolve Transform component");
Assert.AreEqual(typeof(Transform), transformType, "Should return correct Transform type"); Assert.AreEqual(typeof(Transform), transformType, "Should return correct Transform type");
Assert.IsEmpty(error, "Should have no error for valid component"); Assert.IsEmpty(error, "Should have no error for valid component");
@ -86,7 +86,7 @@ namespace MCPForUnityTests.Editor.Tools
var components = new[] var components = new[]
{ {
("Rigidbody", typeof(Rigidbody)), ("Rigidbody", typeof(Rigidbody)),
("Collider", typeof(Collider)), ("Collider", typeof(Collider)),
("Renderer", typeof(Renderer)), ("Renderer", typeof(Renderer)),
("Camera", typeof(Camera)), ("Camera", typeof(Camera)),
("Light", typeof(Light)) ("Light", typeof(Light))
@ -95,11 +95,11 @@ namespace MCPForUnityTests.Editor.Tools
foreach (var (componentName, expectedType) in components) foreach (var (componentName, expectedType) in components)
{ {
var result = ComponentResolver.TryResolve(componentName, out Type actualType, out string error); var result = ComponentResolver.TryResolve(componentName, out Type actualType, out string error);
// Some components might not resolve (abstract classes), but the method should handle gracefully // Some components might not resolve (abstract classes), but the method should handle gracefully
if (result) if (result)
{ {
Assert.IsTrue(expectedType.IsAssignableFrom(actualType), Assert.IsTrue(expectedType.IsAssignableFrom(actualType),
$"{componentName} should resolve to assignable type"); $"{componentName} should resolve to assignable type");
} }
else else
@ -114,13 +114,13 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// Add a Rigidbody to test real property matching // Add a Rigidbody to test real property matching
var rigidbody = testGameObject.AddComponent<Rigidbody>(); var rigidbody = testGameObject.AddComponent<Rigidbody>();
var properties = ComponentResolver.GetAllComponentProperties(typeof(Rigidbody)); var properties = ComponentResolver.GetAllComponentProperties(typeof(Rigidbody));
Assert.IsNotEmpty(properties, "Rigidbody should have properties"); Assert.IsNotEmpty(properties, "Rigidbody should have properties");
Assert.Contains("mass", properties, "Rigidbody should have mass property"); Assert.Contains("mass", properties, "Rigidbody should have mass property");
Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property"); Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property");
// Test AI suggestions // Test AI suggestions
var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties); var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties);
Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'"); Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'");
@ -130,18 +130,18 @@ namespace MCPForUnityTests.Editor.Tools
public void PropertyMatching_HandlesMonoBehaviourProperties() public void PropertyMatching_HandlesMonoBehaviourProperties()
{ {
var properties = ComponentResolver.GetAllComponentProperties(typeof(MonoBehaviour)); var properties = ComponentResolver.GetAllComponentProperties(typeof(MonoBehaviour));
Assert.IsNotEmpty(properties, "MonoBehaviour should have properties"); Assert.IsNotEmpty(properties, "MonoBehaviour should have properties");
Assert.Contains("enabled", properties, "MonoBehaviour should have enabled property"); Assert.Contains("enabled", properties, "MonoBehaviour should have enabled property");
Assert.Contains("name", properties, "MonoBehaviour should have name property"); Assert.Contains("name", properties, "MonoBehaviour should have name property");
Assert.Contains("tag", properties, "MonoBehaviour should have tag property"); Assert.Contains("tag", properties, "MonoBehaviour should have tag property");
} }
[Test] [Test]
public void PropertyMatching_HandlesCaseVariations() public void PropertyMatching_HandlesCaseVariations()
{ {
var testProperties = new List<string> { "maxReachDistance", "playerHealth", "movementSpeed" }; var testProperties = new List<string> { "maxReachDistance", "playerHealth", "movementSpeed" };
var testCases = new[] var testCases = new[]
{ {
("max reach distance", "maxReachDistance"), ("max reach distance", "maxReachDistance"),
@ -164,10 +164,10 @@ namespace MCPForUnityTests.Editor.Tools
// This test verifies that error messages are helpful and contain suggestions // This test verifies that error messages are helpful and contain suggestions
var testProperties = new List<string> { "mass", "velocity", "drag", "useGravity" }; var testProperties = new List<string> { "mass", "velocity", "drag", "useGravity" };
var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties); var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties);
// Even if no perfect match, should return valid list // Even if no perfect match, should return valid list
Assert.IsNotNull(suggestions, "Should return valid suggestions list"); Assert.IsNotNull(suggestions, "Should return valid suggestions list");
// Test with completely invalid input // Test with completely invalid input
var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties); var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties);
Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully"); Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully");
@ -178,20 +178,20 @@ namespace MCPForUnityTests.Editor.Tools
{ {
var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform));
var input = "Test Property Name"; var input = "Test Property Name";
// First call - populate cache // First call - populate cache
var startTime = System.DateTime.UtcNow; var startTime = System.DateTime.UtcNow;
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties); var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties);
var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
// Second call - should use cache // Second call - should use cache
startTime = System.DateTime.UtcNow; startTime = System.DateTime.UtcNow;
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties);
var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical"); Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical");
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should match exactly"); CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should match exactly");
// Second call should be faster (though this test might be flaky) // Second call should be faster (though this test might be flaky)
Assert.LessOrEqual(secondCallTime, firstCallTime * 2, "Cached call should not be significantly slower"); Assert.LessOrEqual(secondCallTime, firstCallTime * 2, "Cached call should not be significantly slower");
} }
@ -202,13 +202,13 @@ namespace MCPForUnityTests.Editor.Tools
// Arrange - add Transform and Rigidbody components to test with // Arrange - add Transform and Rigidbody components to test with
var transform = testGameObject.transform; var transform = testGameObject.transform;
var rigidbody = testGameObject.AddComponent<Rigidbody>(); var rigidbody = testGameObject.AddComponent<Rigidbody>();
// Create a params object with mixed valid and invalid properties // Create a params object with mixed valid and invalid properties
var setPropertiesParams = new JObject var setPropertiesParams = new JObject
{ {
["action"] = "modify", ["action"] = "modify",
["target"] = testGameObject.name, ["target"] = testGameObject.name,
["search_method"] = "by_name", ["search_method"] = "by_name",
["componentProperties"] = new JObject ["componentProperties"] = new JObject
{ {
["Transform"] = new JObject ["Transform"] = new JObject
@ -217,7 +217,7 @@ namespace MCPForUnityTests.Editor.Tools
["rotatoin"] = new JObject { ["x"] = 0.0f, ["y"] = 90.0f, ["z"] = 0.0f }, // Invalid (typo - should be rotation) ["rotatoin"] = new JObject { ["x"] = 0.0f, ["y"] = 90.0f, ["z"] = 0.0f }, // Invalid (typo - should be rotation)
["localScale"] = new JObject { ["x"] = 2.0f, ["y"] = 2.0f, ["z"] = 2.0f } // Valid ["localScale"] = new JObject { ["x"] = 2.0f, ["y"] = 2.0f, ["z"] = 2.0f } // Valid
}, },
["Rigidbody"] = new JObject ["Rigidbody"] = new JObject
{ {
["mass"] = 5.0f, // Valid ["mass"] = 5.0f, // Valid
["invalidProp"] = "test", // Invalid - doesn't exist ["invalidProp"] = "test", // Invalid - doesn't exist
@ -231,7 +231,7 @@ namespace MCPForUnityTests.Editor.Tools
var originalLocalScale = transform.localScale; var originalLocalScale = transform.localScale;
var originalMass = rigidbody.mass; var originalMass = rigidbody.mass;
var originalUseGravity = rigidbody.useGravity; var originalUseGravity = rigidbody.useGravity;
Debug.Log($"BEFORE TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); Debug.Log($"BEFORE TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}");
// Expect the warning logs from the invalid properties // Expect the warning logs from the invalid properties
@ -240,13 +240,13 @@ namespace MCPForUnityTests.Editor.Tools
// Act // Act
var result = ManageGameObject.HandleCommand(setPropertiesParams); var result = ManageGameObject.HandleCommand(setPropertiesParams);
Debug.Log($"AFTER TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); Debug.Log($"AFTER TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}");
Debug.Log($"AFTER TEST - LocalPosition: {transform.localPosition}"); Debug.Log($"AFTER TEST - LocalPosition: {transform.localPosition}");
Debug.Log($"AFTER TEST - LocalScale: {transform.localScale}"); Debug.Log($"AFTER TEST - LocalScale: {transform.localScale}");
// Assert - verify that valid properties were set despite invalid ones // Assert - verify that valid properties were set despite invalid ones
Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition, Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition,
"Valid localPosition should be set even with other invalid properties"); "Valid localPosition should be set even with other invalid properties");
Assert.AreEqual(new Vector3(2.0f, 2.0f, 2.0f), transform.localScale, Assert.AreEqual(new Vector3(2.0f, 2.0f, 2.0f), transform.localScale,
"Valid localScale should be set even with other invalid properties"); "Valid localScale should be set even with other invalid properties");
@ -257,7 +257,7 @@ namespace MCPForUnityTests.Editor.Tools
// Verify the result indicates errors (since we had invalid properties) // Verify the result indicates errors (since we had invalid properties)
Assert.IsNotNull(result, "Should return a result object"); Assert.IsNotNull(result, "Should return a result object");
// The collect-and-continue behavior means we should get an error response // The collect-and-continue behavior means we should get an error response
// that contains info about the failed properties, but valid ones were still applied // that contains info about the failed properties, but valid ones were still applied
// This proves the collect-and-continue behavior is working // This proves the collect-and-continue behavior is working
@ -288,16 +288,16 @@ namespace MCPForUnityTests.Editor.Tools
Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property"); Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property");
} }
[Test] [Test]
public void SetComponentProperties_ContinuesAfterException() public void SetComponentProperties_ContinuesAfterException()
{ {
// Arrange - create scenario that might cause exceptions // Arrange - create scenario that might cause exceptions
var rigidbody = testGameObject.AddComponent<Rigidbody>(); var rigidbody = testGameObject.AddComponent<Rigidbody>();
// Set initial values that we'll change // Set initial values that we'll change
rigidbody.mass = 1.0f; rigidbody.mass = 1.0f;
rigidbody.useGravity = true; rigidbody.useGravity = true;
var setPropertiesParams = new JObject var setPropertiesParams = new JObject
{ {
["action"] = "modify", ["action"] = "modify",
@ -329,7 +329,7 @@ namespace MCPForUnityTests.Editor.Tools
"UseGravity should be set even if previous property caused exception"); "UseGravity should be set even if previous property caused exception");
Assert.IsNotNull(result, "Should return a result even with exceptions"); Assert.IsNotNull(result, "Should return a result even with exceptions");
// The key test: processing continued after the exception and set useGravity // The key test: processing continued after the exception and set useGravity
// This proves the collect-and-continue behavior works even with exceptions // This proves the collect-and-continue behavior works even with exceptions
@ -356,4 +356,4 @@ namespace MCPForUnityTests.Editor.Tools
Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'"); Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'");
} }
} }
} }

View File

@ -29,7 +29,7 @@ namespace MCPForUnityTests.Editor.Tools
["name"] = "TestScript", ["name"] = "TestScript",
["path"] = "Assets/Scripts" ["path"] = "Assets/Scripts"
}; };
var result = ManageScript.HandleCommand(paramsObj); var result = ManageScript.HandleCommand(paramsObj);
Assert.IsNotNull(result, "Should return error result for invalid action"); Assert.IsNotNull(result, "Should return error result for invalid action");
} }
@ -38,7 +38,7 @@ namespace MCPForUnityTests.Editor.Tools
public void CheckBalancedDelimiters_ValidCode_ReturnsTrue() public void CheckBalancedDelimiters_ValidCode_ReturnsTrue()
{ {
string validCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n }\n}"; string validCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n }\n}";
bool result = CallCheckBalancedDelimiters(validCode, out int line, out char expected); bool result = CallCheckBalancedDelimiters(validCode, out int line, out char expected);
Assert.IsTrue(result, "Valid C# code should pass balance check"); Assert.IsTrue(result, "Valid C# code should pass balance check");
} }
@ -47,7 +47,7 @@ namespace MCPForUnityTests.Editor.Tools
public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse() public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse()
{ {
string unbalancedCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n // Missing closing brace"; string unbalancedCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n // Missing closing brace";
bool result = CallCheckBalancedDelimiters(unbalancedCode, out int line, out char expected); bool result = CallCheckBalancedDelimiters(unbalancedCode, out int line, out char expected);
Assert.IsFalse(result, "Unbalanced code should fail balance check"); Assert.IsFalse(result, "Unbalanced code should fail balance check");
} }
@ -56,16 +56,16 @@ namespace MCPForUnityTests.Editor.Tools
public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue() public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue()
{ {
string codeWithStringBraces = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n public string json = \"{key: value}\";\n void Start() { Debug.Log(json); }\n}"; string codeWithStringBraces = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n public string json = \"{key: value}\";\n void Start() { Debug.Log(json); }\n}";
bool result = CallCheckBalancedDelimiters(codeWithStringBraces, out int line, out char expected); bool result = CallCheckBalancedDelimiters(codeWithStringBraces, out int line, out char expected);
Assert.IsTrue(result, "Code with braces in strings should pass balance check"); Assert.IsTrue(result, "Code with braces in strings should pass balance check");
} }
[Test] [Test]
public void CheckScopedBalance_ValidCode_ReturnsTrue() public void CheckScopedBalance_ValidCode_ReturnsTrue()
{ {
string validCode = "{ Debug.Log(\"test\"); }"; string validCode = "{ Debug.Log(\"test\"); }";
bool result = CallCheckScopedBalance(validCode, 0, validCode.Length); bool result = CallCheckScopedBalance(validCode, 0, validCode.Length);
Assert.IsTrue(result, "Valid scoped code should pass balance check"); Assert.IsTrue(result, "Valid scoped code should pass balance check");
} }
@ -75,9 +75,9 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// This simulates a snippet extracted from a larger context // This simulates a snippet extracted from a larger context
string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context"; string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context";
bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length); bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length);
// Scoped balance should tolerate some imbalance from outer context // Scoped balance should tolerate some imbalance from outer context
Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance"); Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance");
} }
@ -87,11 +87,11 @@ namespace MCPForUnityTests.Editor.Tools
{ {
// Test the scenario that was causing issues without file I/O // Test the scenario that was causing issues without file I/O
string ticTacToeCode = "using UnityEngine;\n\npublic class TicTacToe3D : MonoBehaviour\n{\n public string gameState = \"active\";\n void Start() { Debug.Log(\"Game started\"); }\n public void MakeMove(int position) { if (gameState == \"active\") Debug.Log($\"Move {position}\"); }\n}"; string ticTacToeCode = "using UnityEngine;\n\npublic class TicTacToe3D : MonoBehaviour\n{\n public string gameState = \"active\";\n void Start() { Debug.Log(\"Game started\"); }\n public void MakeMove(int position) { if (gameState == \"active\") Debug.Log($\"Move {position}\"); }\n}";
// Test that the validation methods don't crash on this code // Test that the validation methods don't crash on this code
bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected); bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected);
bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length); bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length);
Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation"); Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation");
Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation"); Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation");
} }
@ -101,12 +101,12 @@ namespace MCPForUnityTests.Editor.Tools
{ {
line = 0; line = 0;
expected = ' '; expected = ' ';
try try
{ {
var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters", var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters",
BindingFlags.NonPublic | BindingFlags.Static); BindingFlags.NonPublic | BindingFlags.Static);
if (method != null) if (method != null)
{ {
var parameters = new object[] { contents, line, expected }; var parameters = new object[] { contents, line, expected };
@ -120,7 +120,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
Debug.LogWarning($"Could not test CheckBalancedDelimiters directly: {ex.Message}"); Debug.LogWarning($"Could not test CheckBalancedDelimiters directly: {ex.Message}");
} }
// Fallback: basic structural check // Fallback: basic structural check
return BasicBalanceCheck(contents); return BasicBalanceCheck(contents);
} }
@ -129,9 +129,9 @@ namespace MCPForUnityTests.Editor.Tools
{ {
try try
{ {
var method = typeof(ManageScript).GetMethod("CheckScopedBalance", var method = typeof(ManageScript).GetMethod("CheckScopedBalance",
BindingFlags.NonPublic | BindingFlags.Static); BindingFlags.NonPublic | BindingFlags.Static);
if (method != null) if (method != null)
{ {
return (bool)method.Invoke(null, new object[] { text, start, end }); return (bool)method.Invoke(null, new object[] { text, start, end });
@ -141,7 +141,7 @@ namespace MCPForUnityTests.Editor.Tools
{ {
Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}"); Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}");
} }
return true; // Default to passing if we can't test the actual method return true; // Default to passing if we can't test the actual method
} }
@ -151,32 +151,32 @@ namespace MCPForUnityTests.Editor.Tools
int braceCount = 0; int braceCount = 0;
bool inString = false; bool inString = false;
bool escaped = false; bool escaped = false;
for (int i = 0; i < contents.Length; i++) for (int i = 0; i < contents.Length; i++)
{ {
char c = contents[i]; char c = contents[i];
if (escaped) if (escaped)
{ {
escaped = false; escaped = false;
continue; continue;
} }
if (inString) if (inString)
{ {
if (c == '\\') escaped = true; if (c == '\\') escaped = true;
else if (c == '"') inString = false; else if (c == '"') inString = false;
continue; continue;
} }
if (c == '"') inString = true; if (c == '"') inString = true;
else if (c == '{') braceCount++; else if (c == '{') braceCount++;
else if (c == '}') braceCount--; else if (c == '}') braceCount--;
if (braceCount < 0) return false; if (braceCount < 0) return false;
} }
return braceCount == 0; return braceCount == 0;
} }
} }
} }

View File

@ -1,3 +1,3 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] [assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")]

View File

@ -15,4 +15,3 @@ namespace MCPForUnity.Editor.Data
public new float retryDelay = 1.0f; public new float retryDelay = 1.0f;
} }
} }

View File

@ -121,19 +121,19 @@ namespace MCPForUnity.External.Tommy
#region Native type to TOML cast #region Native type to TOML cast
public static implicit operator TomlNode(string value) => new TomlString {Value = value}; public static implicit operator TomlNode(string value) => new TomlString { Value = value };
public static implicit operator TomlNode(bool value) => new TomlBoolean {Value = value}; public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value };
public static implicit operator TomlNode(long value) => new TomlInteger {Value = value}; public static implicit operator TomlNode(long value) => new TomlInteger { Value = value };
public static implicit operator TomlNode(float value) => new TomlFloat {Value = value}; public static implicit operator TomlNode(float value) => new TomlFloat { Value = value };
public static implicit operator TomlNode(double value) => new TomlFloat {Value = value}; public static implicit operator TomlNode(double value) => new TomlFloat { Value = value };
public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal {Value = value}; public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value };
public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset {Value = value}; public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value };
public static implicit operator TomlNode(TomlNode[] nodes) public static implicit operator TomlNode(TomlNode[] nodes)
{ {
@ -148,11 +148,11 @@ namespace MCPForUnity.External.Tommy
public static implicit operator string(TomlNode value) => value.ToString(); public static implicit operator string(TomlNode value) => value.ToString();
public static implicit operator int(TomlNode value) => (int) value.AsInteger.Value; public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value;
public static implicit operator long(TomlNode value) => value.AsInteger.Value; public static implicit operator long(TomlNode value) => value.AsInteger.Value;
public static implicit operator float(TomlNode value) => (float) value.AsFloat.Value; public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value;
public static implicit operator double(TomlNode value) => value.AsFloat.Value; public static implicit operator double(TomlNode value) => value.AsFloat.Value;
@ -212,7 +212,7 @@ namespace MCPForUnity.External.Tommy
public override string ToInlineToml() => public override string ToInlineToml() =>
IntegerBase != Base.Decimal IntegerBase != Base.Decimal
? $"0{TomlSyntax.BaseIdentifiers[(int) IntegerBase]}{Convert.ToString(Value, (int) IntegerBase)}" ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}"
: Value.ToString(CultureInfo.InvariantCulture); : Value.ToString(CultureInfo.InvariantCulture);
} }
@ -232,10 +232,10 @@ namespace MCPForUnity.External.Tommy
public override string ToInlineToml() => public override string ToInlineToml() =>
Value switch Value switch
{ {
var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE,
var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE,
var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE,
var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant()
}; };
} }
@ -286,7 +286,7 @@ namespace MCPForUnity.External.Tommy
Time, Time,
DateTime DateTime
} }
public override bool IsDateTimeLocal { get; } = true; public override bool IsDateTimeLocal { get; } = true;
public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime;
public DateTime Value { get; set; } public DateTime Value { get; set; }
@ -303,7 +303,7 @@ namespace MCPForUnity.External.Tommy
{ {
DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat),
DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]),
var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision])
}; };
} }
@ -422,12 +422,12 @@ namespace MCPForUnity.External.Tommy
{ {
private Dictionary<string, TomlNode> children; private Dictionary<string, TomlNode> children;
internal bool isImplicit; internal bool isImplicit;
public override bool HasValue { get; } = false; public override bool HasValue { get; } = false;
public override bool IsTable { get; } = true; public override bool IsTable { get; } = true;
public bool IsInline { get; set; } public bool IsInline { get; set; }
public Dictionary<string, TomlNode> RawTable => children ??= new Dictionary<string, TomlNode>(); public Dictionary<string, TomlNode> RawTable => children ??= new Dictionary<string, TomlNode>();
public override TomlNode this[string key] public override TomlNode this[string key]
{ {
get get
@ -478,7 +478,7 @@ namespace MCPForUnity.External.Tommy
{ {
var node = keyValuePair.Value; var node = keyValuePair.Value;
var key = keyValuePair.Key.AsKey(); var key = keyValuePair.Key.AsKey();
if (node is TomlTable tbl) if (node is TomlTable tbl)
{ {
var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder);
@ -493,7 +493,7 @@ namespace MCPForUnity.External.Tommy
else if (node.CollapseLevel == level) else if (node.CollapseLevel == level)
nodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node)); nodes.AddLast(new KeyValuePair<string, TomlNode>($"{prefix}{key}", node));
} }
if (normalizeOrder) if (normalizeOrder)
foreach (var kv in postNodes) foreach (var kv in postNodes)
nodes.AddLast(kv); nodes.AddLast(kv);
@ -513,11 +513,11 @@ namespace MCPForUnity.External.Tommy
} }
var collapsedItems = CollectCollapsedItems(); var collapsedItems = CollectCollapsedItems();
if (collapsedItems.Count == 0) if (collapsedItems.Count == 0)
return; return;
var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable {IsInline: false} or TomlArray {IsTableArray: true}); var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true });
Comment?.AsComment(tw); Comment?.AsComment(tw);
@ -539,7 +539,7 @@ namespace MCPForUnity.External.Tommy
foreach (var collapsedItem in collapsedItems) foreach (var collapsedItem in collapsedItems)
{ {
var key = collapsedItem.Key; var key = collapsedItem.Key;
if (collapsedItem.Value is TomlArray {IsTableArray: true} or TomlTable {IsInline: false}) if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false })
{ {
if (!first) tw.WriteLine(); if (!first) tw.WriteLine();
first = false; first = false;
@ -547,13 +547,13 @@ namespace MCPForUnity.External.Tommy
continue; continue;
} }
first = false; first = false;
collapsedItem.Value.Comment?.AsComment(tw); collapsedItem.Value.Comment?.AsComment(tw);
tw.Write(key); tw.Write(key);
tw.Write(' '); tw.Write(' ');
tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR);
tw.Write(' '); tw.Write(' ');
collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}");
} }
} }
@ -660,7 +660,7 @@ namespace MCPForUnity.External.Tommy
int currentChar; int currentChar;
while ((currentChar = reader.Peek()) >= 0) while ((currentChar = reader.Peek()) >= 0)
{ {
var c = (char) currentChar; var c = (char)currentChar;
if (currentState == ParseState.None) if (currentState == ParseState.None)
{ {
@ -771,7 +771,7 @@ namespace MCPForUnity.External.Tommy
// Consume the ending bracket so we can peek the next character // Consume the ending bracket so we can peek the next character
ConsumeChar(); ConsumeChar();
var nextChar = reader.Peek(); var nextChar = reader.Peek();
if (nextChar < 0 || (char) nextChar != TomlSyntax.TABLE_END_SYMBOL) if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL)
{ {
AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); AddError($"Array table {".".Join(keyParts)} has only one closing bracket.");
keyParts.Clear(); keyParts.Clear();
@ -837,7 +837,7 @@ namespace MCPForUnity.External.Tommy
AddError($"Unexpected character \"{c}\" at the end of the line."); AddError($"Unexpected character \"{c}\" at the end of the line.");
} }
consume_character: consume_character:
reader.Read(); reader.Read();
col++; col++;
} }
@ -858,7 +858,7 @@ namespace MCPForUnity.External.Tommy
if (skipLine) if (skipLine)
{ {
reader.ReadLine(); reader.ReadLine();
AdvanceLine(1); AdvanceLine(1);
} }
currentState = ParseState.None; currentState = ParseState.None;
return false; return false;
@ -892,7 +892,7 @@ namespace MCPForUnity.External.Tommy
int cur; int cur;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c))
{ {
@ -941,7 +941,7 @@ namespace MCPForUnity.External.Tommy
int cur; int cur;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (TomlSyntax.IsWhiteSpace(c)) if (TomlSyntax.IsWhiteSpace(c))
{ {
@ -982,7 +982,7 @@ namespace MCPForUnity.External.Tommy
if (value is null) if (value is null)
return null; return null;
return new TomlString return new TomlString
{ {
Value = value, Value = value,
@ -994,8 +994,8 @@ namespace MCPForUnity.External.Tommy
return c switch return c switch
{ {
TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(),
TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), TomlSyntax.ARRAY_START_SYMBOL => ReadArray(),
var _ => ReadTomlValue() var _ => ReadTomlValue()
}; };
} }
@ -1023,7 +1023,7 @@ namespace MCPForUnity.External.Tommy
int cur; int cur;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
// Reached the final character // Reached the final character
if (c == until) break; if (c == until) break;
@ -1062,7 +1062,7 @@ namespace MCPForUnity.External.Tommy
// Consume the quote character and read the key name // Consume the quote character and read the key name
col++; col++;
buffer.Append(ReadQuotedValueSingleLine((char) reader.Read())); buffer.Append(ReadQuotedValueSingleLine((char)reader.Read()));
quoted = true; quoted = true;
continue; continue;
} }
@ -1076,7 +1076,7 @@ namespace MCPForUnity.External.Tommy
// If we see an invalid symbol, let the next parser handle it // If we see an invalid symbol, let the next parser handle it
break; break;
consume_character: consume_character:
reader.Read(); reader.Read();
col++; col++;
} }
@ -1107,7 +1107,7 @@ namespace MCPForUnity.External.Tommy
int cur; int cur;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break;
result.Append(c); result.Append(c);
ConsumeChar(); ConsumeChar();
@ -1134,9 +1134,9 @@ namespace MCPForUnity.External.Tommy
TomlNode node = value switch TomlNode node = value switch
{ {
var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), var v when TomlSyntax.IsBoolean(v) => bool.Parse(v),
var v when TomlSyntax.IsNaN(v) => double.NaN, var v when TomlSyntax.IsNaN(v) => double.NaN,
var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity,
var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity,
var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
CultureInfo.InvariantCulture), CultureInfo.InvariantCulture),
var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
@ -1144,7 +1144,7 @@ namespace MCPForUnity.External.Tommy
var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger
{ {
Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase),
IntegerBase = (TomlInteger.Base) numberBase IntegerBase = (TomlInteger.Base)numberBase
}, },
var _ => null var _ => null
}; };
@ -1187,7 +1187,7 @@ namespace MCPForUnity.External.Tommy
Style = TomlDateTimeLocal.DateTimeStyle.Time, Style = TomlDateTimeLocal.DateTimeStyle.Time,
SecondsPrecision = precision SecondsPrecision = precision
}; };
if (StringUtils.TryParseDateTime<DateTimeOffset>(value, if (StringUtils.TryParseDateTime<DateTimeOffset>(value,
TomlSyntax.RFC3339Formats, TomlSyntax.RFC3339Formats,
DateTimeStyles.None, DateTimeStyles.None,
@ -1223,7 +1223,7 @@ namespace MCPForUnity.External.Tommy
int cur; int cur;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (c == TomlSyntax.ARRAY_END_SYMBOL) if (c == TomlSyntax.ARRAY_END_SYMBOL)
{ {
@ -1274,7 +1274,7 @@ namespace MCPForUnity.External.Tommy
expectValue = false; expectValue = false;
continue; continue;
consume_character: consume_character:
ConsumeChar(); ConsumeChar();
} }
@ -1293,14 +1293,14 @@ namespace MCPForUnity.External.Tommy
private TomlNode ReadInlineTable() private TomlNode ReadInlineTable()
{ {
ConsumeChar(); ConsumeChar();
var result = new TomlTable {IsInline = true}; var result = new TomlTable { IsInline = true };
TomlNode currentValue = null; TomlNode currentValue = null;
var separator = false; var separator = false;
var keyParts = new List<string>(); var keyParts = new List<string>();
int cur; int cur;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL)
{ {
@ -1343,7 +1343,7 @@ namespace MCPForUnity.External.Tommy
currentValue = ReadKeyValuePair(keyParts); currentValue = ReadKeyValuePair(keyParts);
continue; continue;
consume_character: consume_character:
ConsumeChar(); ConsumeChar();
} }
@ -1352,7 +1352,7 @@ namespace MCPForUnity.External.Tommy
AddError("Trailing commas are not allowed in inline tables."); AddError("Trailing commas are not allowed in inline tables.");
return null; return null;
} }
if (currentValue != null && !InsertNode(currentValue, result, keyParts)) if (currentValue != null && !InsertNode(currentValue, result, keyParts))
return null; return null;
@ -1394,15 +1394,15 @@ namespace MCPForUnity.External.Tommy
return AddError("Unexpected end of file!"); return AddError("Unexpected end of file!");
} }
if ((char) cur != quote) if ((char)cur != quote)
{ {
excess = '\0'; excess = '\0';
return false; return false;
} }
// Consume the second quote // Consume the second quote
excess = (char) ConsumeChar(); excess = (char)ConsumeChar();
if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false; if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false;
// Consume the final quote // Consume the final quote
ConsumeChar(); ConsumeChar();
@ -1420,7 +1420,7 @@ namespace MCPForUnity.External.Tommy
ref bool escaped) ref bool escaped)
{ {
if (TomlSyntax.MustBeEscaped(c)) if (TomlSyntax.MustBeEscaped(c))
return AddError($"The character U+{(int) c:X8} must be escaped in a string!"); return AddError($"The character U+{(int)c:X8} must be escaped in a string!");
if (escaped) if (escaped)
{ {
@ -1487,7 +1487,7 @@ namespace MCPForUnity.External.Tommy
{ {
// Consume the character // Consume the character
col++; col++;
var c = (char) cur; var c = (char)cur;
readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped);
if (readDone) if (readDone)
{ {
@ -1529,10 +1529,10 @@ namespace MCPForUnity.External.Tommy
int cur; int cur;
while ((cur = ConsumeChar()) >= 0) while ((cur = ConsumeChar()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (TomlSyntax.MustBeEscaped(c, true)) if (TomlSyntax.MustBeEscaped(c, true))
{ {
AddError($"The character U+{(int) c:X8} must be escaped!"); AddError($"The character U+{(int)c:X8} must be escaped!");
return null; return null;
} }
// Trim the first newline // Trim the first newline
@ -1582,7 +1582,7 @@ namespace MCPForUnity.External.Tommy
if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL)
{ {
var next = reader.Peek(); var next = reader.Peek();
var nc = (char) next; var nc = (char)next;
if (next >= 0) if (next >= 0)
{ {
// ...and the next char is empty space, we must skip all whitespaces // ...and the next char is empty space, we must skip all whitespaces
@ -1614,7 +1614,7 @@ namespace MCPForUnity.External.Tommy
quotesEncountered = 0; quotesEncountered = 0;
while ((cur = reader.Peek()) >= 0) while ((cur = reader.Peek()) >= 0)
{ {
var c = (char) cur; var c = (char)cur;
if (c == quote && ++quotesEncountered < 3) if (c == quote && ++quotesEncountered < 3)
{ {
sb.Append(c); sb.Append(c);
@ -1677,7 +1677,7 @@ namespace MCPForUnity.External.Tommy
{ {
if (node.IsArray && arrayTable) if (node.IsArray && arrayTable)
{ {
var arr = (TomlArray) node; var arr = (TomlArray)node;
if (!arr.IsTableArray) if (!arr.IsTableArray)
{ {
@ -1695,7 +1695,7 @@ namespace MCPForUnity.External.Tommy
latestNode = arr[arr.ChildrenCount - 1]; latestNode = arr[arr.ChildrenCount - 1];
continue; continue;
} }
if (node is TomlTable { IsInline: true }) if (node is TomlTable { IsInline: true })
{ {
AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table.");
@ -1751,13 +1751,13 @@ namespace MCPForUnity.External.Tommy
latestNode = node; latestNode = node;
} }
var result = (TomlTable) latestNode; var result = (TomlTable)latestNode;
result.isImplicit = false; result.isImplicit = false;
return result; return result;
} }
#endregion #endregion
#region Misc parsing #region Misc parsing
private string ParseComment() private string ParseComment()
@ -1779,7 +1779,7 @@ namespace MCPForUnity.External.Tommy
public static TomlTable Parse(TextReader reader) public static TomlTable Parse(TextReader reader)
{ {
using var parser = new TOMLParser(reader) {ForceASCII = ForceASCII}; using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII };
return parser.Parse(); return parser.Parse();
} }
} }
@ -1960,7 +1960,7 @@ namespace MCPForUnity.External.Tommy
public const char LITERAL_STRING_SYMBOL = '\''; public const char LITERAL_STRING_SYMBOL = '\'';
public const char INT_NUMBER_SEPARATOR = '_'; public const char INT_NUMBER_SEPARATOR = '_';
public static readonly char[] NewLineCharacters = {NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER}; public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER };
public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL;
@ -2013,7 +2013,7 @@ namespace MCPForUnity.External.Tommy
} }
public delegate bool TryDateParseDelegate<T>(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); public delegate bool TryDateParseDelegate<T>(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt);
public static bool TryParseDateTime<T>(string s, public static bool TryParseDateTime<T>(string s,
string[] formats, string[] formats,
DateTimeStyles styles, DateTimeStyles styles,
@ -2057,17 +2057,17 @@ namespace MCPForUnity.External.Tommy
static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i)
? $"\\U{char.ConvertToUtf32(txt, i++):X8}" ? $"\\U{char.ConvertToUtf32(txt, i++):X8}"
: $"\\u{(ushort) c:X4}"; : $"\\u{(ushort)c:X4}";
stringBuilder.Append(c switch stringBuilder.Append(c switch
{ {
'\b' => @"\b", '\b' => @"\b",
'\t' => @"\t", '\t' => @"\t",
'\n' when escapeNewlines => @"\n", '\n' when escapeNewlines => @"\n",
'\f' => @"\f", '\f' => @"\f",
'\r' when escapeNewlines => @"\r", '\r' when escapeNewlines => @"\r",
'\\' => @"\\", '\\' => @"\\",
'\"' => @"\""", '\"' => @"\""",
var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue =>
CodePoint(txt, ref i, c), CodePoint(txt, ref i, c),
var _ => c var _ => c
@ -2092,7 +2092,7 @@ namespace MCPForUnity.External.Tommy
return false; return false;
} }
} }
public static string Unescape(this string txt) public static string Unescape(this string txt)
{ {
if (string.IsNullOrEmpty(txt)) return txt; if (string.IsNullOrEmpty(txt)) return txt;
@ -2115,16 +2115,16 @@ namespace MCPForUnity.External.Tommy
stringBuilder.Append(c switch stringBuilder.Append(c switch
{ {
'b' => "\b", 'b' => "\b",
't' => "\t", 't' => "\t",
'n' => "\n", 'n' => "\n",
'f' => "\f", 'f' => "\f",
'r' => "\r", 'r' => "\r",
'\'' => "\'", '\'' => "\'",
'\"' => "\"", '\"' => "\"",
'\\' => "\\", '\\' => "\\",
'u' => CodePoint(next, txt, ref num, 4), 'u' => CodePoint(next, txt, ref num, 4),
'U' => CodePoint(next, txt, ref num, 8), 'U' => CodePoint(next, txt, ref num, 8),
var _ => throw new Exception("Undefined escape sequence!") var _ => throw new Exception("Undefined escape sequence!")
}); });
i = num + 2; i = num + 2;

View File

@ -205,7 +205,7 @@ namespace MCPForUnity.Editor.Helpers
var so = new StringBuilder(); var so = new StringBuilder();
var se = new StringBuilder(); var se = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); };
if (!process.Start()) return false; if (!process.Start()) return false;
@ -276,5 +276,3 @@ namespace MCPForUnity.Editor.Helpers
#endif #endif
} }
} }

View File

@ -124,7 +124,7 @@ namespace MCPForUnity.Editor.Helpers
// --- Add Early Logging --- // --- Add Early Logging ---
// Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})");
// --- End Early Logging --- // --- End Early Logging ---
if (c == null) return null; if (c == null) return null;
Type componentType = c.GetType(); Type componentType = c.GetType();
@ -150,8 +150,8 @@ namespace MCPForUnity.Editor.Helpers
{ "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 },
{ "childCount", tr.childCount }, { "childCount", tr.childCount },
// Include standard Object/Component properties // Include standard Object/Component properties
{ "name", tr.name }, { "name", tr.name },
{ "tag", tr.tag }, { "tag", tr.tag },
{ "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 }
}; };
} }
@ -244,8 +244,9 @@ namespace MCPForUnity.Editor.Helpers
// Basic filtering (readable, not indexer, not transform which is handled elsewhere) // Basic filtering (readable, not indexer, not transform which is handled elsewhere)
if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue;
// Add if not already added (handles overrides - keep the most derived version) // Add if not already added (handles overrides - keep the most derived version)
if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { if (!propertiesToCache.Any(p => p.Name == propInfo.Name))
propertiesToCache.Add(propInfo); {
propertiesToCache.Add(propInfo);
} }
} }
@ -258,8 +259,8 @@ namespace MCPForUnity.Editor.Helpers
{ {
if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields
// Add if not already added (handles hiding - keep the most derived version) // Add if not already added (handles hiding - keep the most derived version)
if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
bool shouldInclude = false; bool shouldInclude = false;
if (includeNonPublicSerializedFields) if (includeNonPublicSerializedFields)
@ -291,7 +292,7 @@ namespace MCPForUnity.Editor.Helpers
// --- Use cached metadata --- // --- Use cached metadata ---
var serializablePropertiesOutput = new Dictionary<string, object>(); var serializablePropertiesOutput = new Dictionary<string, object>();
// --- Add Logging Before Property Loop --- // --- Add Logging Before Property Loop ---
// Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}..."); // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}...");
// --- End Logging Before Property Loop --- // --- End Logging Before Property Loop ---
@ -310,16 +311,16 @@ namespace MCPForUnity.Editor.Helpers
propName == "particleSystem" || propName == "particleSystem" ||
// Also skip potentially problematic Matrix properties prone to cycles/errors // Also skip potentially problematic Matrix properties prone to cycles/errors
propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")
{ {
// Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
skipProperty = true; skipProperty = true;
} }
// --- End Skip Generic Properties --- // --- End Skip Generic Properties ---
// --- Skip specific potentially problematic Camera properties --- // --- Skip specific potentially problematic Camera properties ---
if (componentType == typeof(Camera) && if (componentType == typeof(Camera) &&
(propName == "pixelRect" || (propName == "pixelRect" ||
propName == "rect" || propName == "rect" ||
propName == "cullingMatrix" || propName == "cullingMatrix" ||
propName == "useOcclusionCulling" || propName == "useOcclusionCulling" ||
propName == "worldToCameraMatrix" || propName == "worldToCameraMatrix" ||
@ -334,8 +335,8 @@ namespace MCPForUnity.Editor.Helpers
// --- End Skip Camera Properties --- // --- End Skip Camera Properties ---
// --- Skip specific potentially problematic Transform properties --- // --- Skip specific potentially problematic Transform properties ---
if (componentType == typeof(Transform) && if (componentType == typeof(Transform) &&
(propName == "lossyScale" || (propName == "lossyScale" ||
propName == "rotation" || propName == "rotation" ||
propName == "worldToLocalMatrix" || propName == "worldToLocalMatrix" ||
propName == "localToWorldMatrix")) propName == "localToWorldMatrix"))
@ -345,11 +346,11 @@ namespace MCPForUnity.Editor.Helpers
} }
// --- End Skip Transform Properties --- // --- End Skip Transform Properties ---
// Skip if flagged // Skip if flagged
if (skipProperty) if (skipProperty)
{ {
continue; continue;
} }
try try
{ {
@ -362,7 +363,7 @@ namespace MCPForUnity.Editor.Helpers
} }
catch (Exception) catch (Exception)
{ {
// Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}");
} }
} }
@ -373,7 +374,7 @@ namespace MCPForUnity.Editor.Helpers
// Use cached fields // Use cached fields
foreach (var fieldInfo in cachedData.SerializableFields) foreach (var fieldInfo in cachedData.SerializableFields)
{ {
try try
{ {
// --- Add detailed logging for fields --- // --- Add detailed logging for fields ---
// Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}");
@ -385,7 +386,7 @@ namespace MCPForUnity.Editor.Helpers
} }
catch (Exception) catch (Exception)
{ {
// Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}");
} }
} }
// --- End Use cached metadata --- // --- End Use cached metadata ---
@ -458,19 +459,19 @@ namespace MCPForUnity.Editor.Helpers
case JTokenType.Boolean: case JTokenType.Boolean:
return token.ToObject<bool>(); return token.ToObject<bool>();
case JTokenType.Date: case JTokenType.Date:
return token.ToObject<DateTime>(); return token.ToObject<DateTime>();
case JTokenType.Guid: case JTokenType.Guid:
return token.ToObject<Guid>(); return token.ToObject<Guid>();
case JTokenType.Uri: case JTokenType.Uri:
return token.ToObject<Uri>(); return token.ToObject<Uri>();
case JTokenType.TimeSpan: case JTokenType.TimeSpan:
return token.ToObject<TimeSpan>(); return token.ToObject<TimeSpan>();
case JTokenType.Bytes: case JTokenType.Bytes:
return token.ToObject<byte[]>(); return token.ToObject<byte[]>();
case JTokenType.Null: case JTokenType.Null:
return null; return null;
case JTokenType.Undefined: case JTokenType.Undefined:
return null; // Treat undefined as null return null; // Treat undefined as null
default: default:
// Fallback for simple value types not explicitly listed // Fallback for simple value types not explicitly listed
@ -524,4 +525,4 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }
} }

View File

@ -184,4 +184,3 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }

View File

@ -29,5 +29,3 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }

View File

@ -105,5 +105,3 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }

View File

@ -10,7 +10,7 @@ namespace MCPForUnity.Editor.Helpers
public static class PackageInstaller public static class PackageInstaller
{ {
private const string InstallationFlagKey = "MCPForUnity.ServerInstalled"; private const string InstallationFlagKey = "MCPForUnity.ServerInstalled";
static PackageInstaller() static PackageInstaller()
{ {
// Check if this is the first time the package is loaded // Check if this is the first time the package is loaded
@ -20,17 +20,17 @@ namespace MCPForUnity.Editor.Helpers
EditorApplication.delayCall += InstallServerOnFirstLoad; EditorApplication.delayCall += InstallServerOnFirstLoad;
} }
} }
private static void InstallServerOnFirstLoad() private static void InstallServerOnFirstLoad()
{ {
try try
{ {
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Installing Python server..."); Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Installing Python server...");
ServerInstaller.EnsureServerInstalled(); ServerInstaller.EnsureServerInstalled();
// Mark as installed // Mark as installed
EditorPrefs.SetBool(InstallationFlagKey, true); EditorPrefs.SetBool(InstallationFlagKey, true);
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Python server installation completed successfully."); Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Python server installation completed successfully.");
} }
catch (System.Exception ex) catch (System.Exception ex)

View File

@ -43,8 +43,8 @@ namespace MCPForUnity.Editor.Helpers
{ {
// Try to load stored port first, but only if it's from the current project // Try to load stored port first, but only if it's from the current project
var storedConfig = GetStoredPortConfig(); var storedConfig = GetStoredPortConfig();
if (storedConfig != null && if (storedConfig != null &&
storedConfig.unity_port > 0 && storedConfig.unity_port > 0 &&
string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) &&
IsPortAvailable(storedConfig.unity_port)) IsPortAvailable(storedConfig.unity_port))
{ {
@ -228,7 +228,7 @@ namespace MCPForUnity.Editor.Helpers
try try
{ {
string registryFile = GetRegistryFilePath(); string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile)) if (!File.Exists(registryFile))
{ {
// Backwards compatibility: try the legacy file name // Backwards compatibility: try the legacy file name
@ -261,7 +261,7 @@ namespace MCPForUnity.Editor.Helpers
try try
{ {
string registryFile = GetRegistryFilePath(); string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile)) if (!File.Exists(registryFile))
{ {
// Backwards compatibility: try the legacy file // Backwards compatibility: try the legacy file
@ -316,4 +316,4 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }
} }

View File

@ -60,4 +60,3 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }

View File

@ -419,7 +419,7 @@ namespace MCPForUnity.Editor.Helpers
try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { }
string destSubDir = Path.Combine(destinationDir, dirName); string destSubDir = Path.Combine(destinationDir, dirName);
CopyDirectoryRecursive(dirPath, destSubDir); CopyDirectoryRecursive(dirPath, destSubDir);
NextDir: ; NextDir:;
} }
} }
@ -467,7 +467,7 @@ namespace MCPForUnity.Editor.Helpers
string uvPath = FindUvPath(); string uvPath = FindUvPath();
if (uvPath == null) if (uvPath == null)
{ {
Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." ); Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/).");
return false; return false;
} }
@ -486,7 +486,7 @@ namespace MCPForUnity.Editor.Helpers
var sbOut = new StringBuilder(); var sbOut = new StringBuilder();
var sbErr = new StringBuilder(); var sbErr = new StringBuilder();
proc.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); }; proc.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); };
proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); };
if (!proc.Start()) if (!proc.Start())
{ {

View File

@ -147,5 +147,3 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }

View File

@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Helpers
private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled";
private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID";
private static Action<Dictionary<string, object>> s_sender; private static Action<Dictionary<string, object>> s_sender;
/// <summary> /// <summary>
/// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs)
/// </summary> /// </summary>
@ -24,14 +24,14 @@ namespace MCPForUnity.Editor.Helpers
{ {
// Check environment variables first // Check environment variables first
var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(envDisable) && if (!string.IsNullOrEmpty(envDisable) &&
(envDisable.ToLower() == "true" || envDisable == "1")) (envDisable.ToLower() == "true" || envDisable == "1"))
{ {
return false; return false;
} }
var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(unityMcpDisable) && if (!string.IsNullOrEmpty(unityMcpDisable) &&
(unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1"))
{ {
return false; return false;
@ -49,7 +49,7 @@ namespace MCPForUnity.Editor.Helpers
return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false);
} }
} }
/// <summary> /// <summary>
/// Get or generate customer UUID for anonymous tracking /// Get or generate customer UUID for anonymous tracking
/// </summary> /// </summary>
@ -63,7 +63,7 @@ namespace MCPForUnity.Editor.Helpers
} }
return uuid; return uuid;
} }
/// <summary> /// <summary>
/// Disable telemetry (stored in EditorPrefs) /// Disable telemetry (stored in EditorPrefs)
/// </summary> /// </summary>
@ -71,7 +71,7 @@ namespace MCPForUnity.Editor.Helpers
{ {
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true);
} }
/// <summary> /// <summary>
/// Enable telemetry (stored in EditorPrefs) /// Enable telemetry (stored in EditorPrefs)
/// </summary> /// </summary>
@ -79,7 +79,7 @@ namespace MCPForUnity.Editor.Helpers
{ {
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false);
} }
/// <summary> /// <summary>
/// Send telemetry data to Python server for processing /// Send telemetry data to Python server for processing
/// This is a lightweight bridge - the actual telemetry logic is in Python /// This is a lightweight bridge - the actual telemetry logic is in Python
@ -88,7 +88,7 @@ namespace MCPForUnity.Editor.Helpers
{ {
if (!IsEnabled) if (!IsEnabled)
return; return;
try try
{ {
var telemetryData = new Dictionary<string, object> var telemetryData = new Dictionary<string, object>
@ -100,12 +100,12 @@ namespace MCPForUnity.Editor.Helpers
["platform"] = Application.platform.ToString(), ["platform"] = Application.platform.ToString(),
["source"] = "unity_bridge" ["source"] = "unity_bridge"
}; };
if (data != null) if (data != null)
{ {
telemetryData["data"] = data; telemetryData["data"] = data;
} }
// Send to Python server via existing bridge communication // Send to Python server via existing bridge communication
// The Python server will handle actual telemetry transmission // The Python server will handle actual telemetry transmission
SendTelemetryToPythonServer(telemetryData); SendTelemetryToPythonServer(telemetryData);
@ -119,7 +119,7 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }
/// <summary> /// <summary>
/// Allows the bridge to register a concrete sender for telemetry payloads. /// Allows the bridge to register a concrete sender for telemetry payloads.
/// </summary> /// </summary>
@ -144,7 +144,7 @@ namespace MCPForUnity.Editor.Helpers
["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode()
}); });
} }
/// <summary> /// <summary>
/// Record bridge connection event /// Record bridge connection event
/// </summary> /// </summary>
@ -154,15 +154,15 @@ namespace MCPForUnity.Editor.Helpers
{ {
["success"] = success ["success"] = success
}; };
if (!string.IsNullOrEmpty(error)) if (!string.IsNullOrEmpty(error))
{ {
data["error"] = error.Substring(0, Math.Min(200, error.Length)); data["error"] = error.Substring(0, Math.Min(200, error.Length));
} }
RecordEvent("bridge_connection", data); RecordEvent("bridge_connection", data);
} }
/// <summary> /// <summary>
/// Record tool execution from Unity side /// Record tool execution from Unity side
/// </summary> /// </summary>
@ -174,15 +174,15 @@ namespace MCPForUnity.Editor.Helpers
["success"] = success, ["success"] = success,
["duration_ms"] = Math.Round(durationMs, 2) ["duration_ms"] = Math.Round(durationMs, 2)
}; };
if (!string.IsNullOrEmpty(error)) if (!string.IsNullOrEmpty(error))
{ {
data["error"] = error.Substring(0, Math.Min(200, error.Length)); data["error"] = error.Substring(0, Math.Min(200, error.Length));
} }
RecordEvent("tool_execution_unity", data); RecordEvent("tool_execution_unity", data);
} }
private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData) private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData)
{ {
var sender = Volatile.Read(ref s_sender); var sender = Volatile.Read(ref s_sender);
@ -208,17 +208,17 @@ namespace MCPForUnity.Editor.Helpers
Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}"); Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}");
} }
} }
private static bool IsDebugEnabled() private static bool IsDebugEnabled()
{ {
try try
{ {
return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
} }
catch catch
{ {
return false; return false;
} }
} }
} }
} }

View File

@ -22,4 +22,3 @@ namespace MCPForUnity.Editor.Helpers
} }
} }
} }

View File

@ -54,7 +54,7 @@ namespace MCPForUnity.Editor
private static bool isAutoConnectMode = false; private static bool isAutoConnectMode = false;
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
// IO diagnostics // IO diagnostics
private static long _ioSeq = 0; private static long _ioSeq = 0;
private static void IoInfo(string s) { McpLog.Info(s, always: false); } private static void IoInfo(string s) { McpLog.Info(s, always: false); }
@ -90,14 +90,14 @@ namespace MCPForUnity.Editor
currentUnityPort = PortManager.GetPortWithFallback(); currentUnityPort = PortManager.GetPortWithFallback();
Start(); Start();
isAutoConnectMode = true; isAutoConnectMode = true;
// Record telemetry for bridge startup // Record telemetry for bridge startup
TelemetryHelper.RecordBridgeStartup(); TelemetryHelper.RecordBridgeStartup();
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError($"Auto-connect failed: {ex.Message}"); Debug.LogError($"Auto-connect failed: {ex.Message}");
// Record telemetry for connection failure // Record telemetry for connection failure
TelemetryHelper.RecordBridgeConnection(false, ex.Message); TelemetryHelper.RecordBridgeConnection(false, ex.Message);
throw; throw;
@ -151,7 +151,8 @@ namespace MCPForUnity.Editor
IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}");
} }
} }
}) { IsBackground = true, Name = "MCP-Writer" }; })
{ IsBackground = true, Name = "MCP-Writer" };
writerThread.Start(); writerThread.Start();
} }
catch { } catch { }
@ -516,159 +517,159 @@ namespace MCPForUnity.Editor
lock (clientsLock) { activeClients.Add(client); } lock (clientsLock) { activeClients.Add(client); }
try try
{ {
// Framed I/O only; legacy mode removed // Framed I/O only; legacy mode removed
try try
{
if (IsDebugEnabled())
{ {
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; if (IsDebugEnabled())
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}"); {
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
}
} }
} catch { }
catch { } // Strict framing: always require FRAMING=1 and frame all I/O
// Strict framing: always require FRAMING=1 and frame all I/O try
try {
{ client.NoDelay = true;
client.NoDelay = true; }
} catch { }
catch { } try
try {
{ string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n";
string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake);
byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
#if NETSTANDARD2_1 || NET6_0_OR_GREATER #if NETSTANDARD2_1 || NET6_0_OR_GREATER
await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false);
#else #else
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
#endif #endif
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
}
catch (Exception ex)
{
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
return; // abort this client
}
while (isRunning && !token.IsCancellationRequested)
{
try
{
// Strict framed mode only: enforced framed I/O for this connection
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
try
{
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();
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// Special handling for ping command to avoid JSON parsing
if (commandText.Trim() == "ping")
{
// Direct response to ping without going through JSON parsing
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
/*lang=json,strict*/
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
);
await WriteFrameAsync(stream, pingResponseBytes);
continue;
}
lock (lockObj)
{
commandQueue[commandId] = (commandText, tcs);
}
// Wait for the handler to produce a response, but do not block indefinitely
string response;
try
{
using var respCts = new CancellationTokenSource(FrameIOTimeoutMs);
var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
if (completed == tcs.Task)
{
// Got a result from the handler
respCts.Cancel();
response = tcs.Task.Result;
}
else
{
// Timeout: return a structured error so the client can recover
var timeoutResponse = new
{
status = "error",
error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
};
response = JsonConvert.SerializeObject(timeoutResponse);
}
}
catch (Exception ex)
{
var errorResponse = new
{
status = "error",
error = ex.Message,
};
response = JsonConvert.SerializeObject(errorResponse);
}
if (IsDebugEnabled())
{
try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
}
// Crash-proof and self-reporting writer logs (direct write to this client's stream)
long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
byte[] responseBytes;
try
{
responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
throw;
}
var swDirect = System.Diagnostics.Stopwatch.StartNew();
try
{
await WriteFrameAsync(stream, responseBytes);
swDirect.Stop();
IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
throw;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
// Treat common disconnects/timeouts as benign; only surface hard errors if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
string msg = ex.Message ?? string.Empty; return; // abort this client
bool isBenign = }
msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 while (isRunning && !token.IsCancellationRequested)
|| ex is System.IO.IOException; {
if (isBenign) try
{ {
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false); // Strict framed mode only: enforced framed I/O for this connection
} string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
else
{ try
MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}"); {
} if (IsDebugEnabled())
break; {
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();
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// Special handling for ping command to avoid JSON parsing
if (commandText.Trim() == "ping")
{
// Direct response to ping without going through JSON parsing
byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
/*lang=json,strict*/
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
);
await WriteFrameAsync(stream, pingResponseBytes);
continue;
}
lock (lockObj)
{
commandQueue[commandId] = (commandText, tcs);
}
// Wait for the handler to produce a response, but do not block indefinitely
string response;
try
{
using var respCts = new CancellationTokenSource(FrameIOTimeoutMs);
var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
if (completed == tcs.Task)
{
// Got a result from the handler
respCts.Cancel();
response = tcs.Task.Result;
}
else
{
// Timeout: return a structured error so the client can recover
var timeoutResponse = new
{
status = "error",
error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
};
response = JsonConvert.SerializeObject(timeoutResponse);
}
}
catch (Exception ex)
{
var errorResponse = new
{
status = "error",
error = ex.Message,
};
response = JsonConvert.SerializeObject(errorResponse);
}
if (IsDebugEnabled())
{
try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
}
// Crash-proof and self-reporting writer logs (direct write to this client's stream)
long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
byte[] responseBytes;
try
{
responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
throw;
}
var swDirect = System.Diagnostics.Stopwatch.StartNew();
try
{
await WriteFrameAsync(stream, responseBytes);
swDirect.Stop();
IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
throw;
}
}
catch (Exception ex)
{
// 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;
}
} }
}
} }
finally finally
{ {
@ -806,116 +807,116 @@ namespace MCPForUnity.Editor
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
try try
{ {
// Heartbeat without holding the queue lock // Heartbeat without holding the queue lock
double now = EditorApplication.timeSinceStartup; double now = EditorApplication.timeSinceStartup;
if (now >= nextHeartbeatAt) if (now >= nextHeartbeatAt)
{
WriteHeartbeat(false);
nextHeartbeatAt = now + 0.5f;
}
// Snapshot under lock, then process outside to reduce contention
List<(string id, string text, TaskCompletionSource<string> tcs)> work;
lock (lockObj)
{
work = commandQueue
.Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs))
.ToList();
}
foreach (var item in work)
{
string id = item.id;
string commandText = item.text;
TaskCompletionSource<string> tcs = item.tcs;
try
{ {
// Special case handling WriteHeartbeat(false);
if (string.IsNullOrEmpty(commandText)) nextHeartbeatAt = now + 0.5f;
}
// Snapshot under lock, then process outside to reduce contention
List<(string id, string text, TaskCompletionSource<string> tcs)> work;
lock (lockObj)
{
work = commandQueue
.Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs))
.ToList();
}
foreach (var item in work)
{
string id = item.id;
string commandText = item.text;
TaskCompletionSource<string> tcs = item.tcs;
try
{ {
var emptyResponse = new // Special case handling
if (string.IsNullOrEmpty(commandText))
{
var emptyResponse = new
{
status = "error",
error = "Empty command received",
};
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Trim the command text to remove any whitespace
commandText = commandText.Trim();
// Non-JSON direct commands handling (like ping)
if (commandText == "ping")
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Check if the command is valid JSON before attempting to deserialize
if (!IsValidJson(commandText))
{
var invalidJsonResponse = new
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50
? commandText[..50] + "..."
: commandText,
};
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Normal JSON command processing
Command command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null)
{
var nullCommandResponse = new
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object",
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
else
{
string responseJson = ExecuteCommand(command);
tcs.SetResult(responseJson);
}
}
catch (Exception ex)
{
Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new
{ {
status = "error", status = "error",
error = "Empty command received", error = ex.Message,
}; commandType = "Unknown (error during processing)",
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); receivedText = commandText?.Length > 50
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Trim the command text to remove any whitespace
commandText = commandText.Trim();
// Non-JSON direct commands handling (like ping)
if (commandText == "ping")
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Check if the command is valid JSON before attempting to deserialize
if (!IsValidJson(commandText))
{
var invalidJsonResponse = new
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50
? commandText[..50] + "..." ? commandText[..50] + "..."
: commandText, : commandText,
}; };
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); string responseJson = JsonConvert.SerializeObject(response);
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Normal JSON command processing
Command command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null)
{
var nullCommandResponse = new
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object",
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
else
{
string responseJson = ExecuteCommand(command);
tcs.SetResult(responseJson); tcs.SetResult(responseJson);
} }
}
catch (Exception ex)
{
Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new // Remove quickly under lock
{ lock (lockObj) { commandQueue.Remove(id); }
status = "error",
error = ex.Message,
commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50
? commandText[..50] + "..."
: commandText,
};
string responseJson = JsonConvert.SerializeObject(response);
tcs.SetResult(responseJson);
} }
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); }
}
} }
finally finally
{ {

View File

@ -48,4 +48,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -27,7 +27,7 @@ namespace MCPForUnity.Editor.Tools
Converters = new List<JsonConverter> Converters = new List<JsonConverter>
{ {
new Vector3Converter(), new Vector3Converter(),
new Vector2Converter(), new Vector2Converter(),
new QuaternionConverter(), new QuaternionConverter(),
new ColorConverter(), new ColorConverter(),
new RectConverter(), new RectConverter(),
@ -35,7 +35,7 @@ namespace MCPForUnity.Editor.Tools
new UnityEngineObjectConverter() new UnityEngineObjectConverter()
} }
}); });
// --- Main Handler --- // --- Main Handler ---
public static object HandleCommand(JObject @params) public static object HandleCommand(JObject @params)
@ -879,7 +879,7 @@ namespace MCPForUnity.Editor.Tools
// return Response.Success( // return Response.Success(
// $"GameObject '{targetGo.name}' modified successfully.", // $"GameObject '{targetGo.name}' modified successfully.",
// GetGameObjectData(targetGo)); // GetGameObjectData(targetGo));
} }
private static object DeleteGameObject(JToken targetToken, string searchMethod) private static object DeleteGameObject(JToken targetToken, string searchMethod)
@ -962,23 +962,23 @@ namespace MCPForUnity.Editor.Tools
// --- Get components, immediately copy to list, and null original array --- // --- Get components, immediately copy to list, and null original array ---
Component[] originalComponents = targetGo.GetComponents<Component>(); Component[] originalComponents = targetGo.GetComponents<Component>();
List<Component> componentsToIterate = new List<Component>(originalComponents ?? Array.Empty<Component>()); // Copy immediately, handle null case List<Component> componentsToIterate = new List<Component>(originalComponents ?? Array.Empty<Component>()); // Copy immediately, handle null case
int componentCount = componentsToIterate.Count; int componentCount = componentsToIterate.Count;
originalComponents = null; // Null the original reference originalComponents = null; // Null the original reference
// Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop...");
// --- End Copy and Null --- // --- End Copy and Null ---
var componentData = new List<object>(); var componentData = new List<object>();
for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY
{ {
Component c = componentsToIterate[i]; // Use the copy Component c = componentsToIterate[i]; // Use the copy
if (c == null) if (c == null)
{ {
// Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping.");
continue; // Safety check continue; // Safety check
} }
// Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}");
try try
{ {
var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized);
if (data != null) // Ensure GetComponentData didn't return null if (data != null) // Ensure GetComponentData didn't return null
@ -1002,7 +1002,7 @@ namespace MCPForUnity.Editor.Tools
} }
} }
// Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop.");
// Cleanup the list we created // Cleanup the list we created
componentsToIterate.Clear(); componentsToIterate.Clear();
componentsToIterate = null; componentsToIterate = null;
@ -1181,7 +1181,7 @@ namespace MCPForUnity.Editor.Tools
return removeResult; // Return error return removeResult; // Return error
EditorUtility.SetDirty(targetGo); EditorUtility.SetDirty(targetGo);
// Use the new serializer helper // Use the new serializer helper
return Response.Success( return Response.Success(
$"Component '{typeName}' removed from '{targetGo.name}'.", $"Component '{typeName}' removed from '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo) Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
@ -1230,7 +1230,7 @@ namespace MCPForUnity.Editor.Tools
return setResult; // Return error return setResult; // Return error
EditorUtility.SetDirty(targetGo); EditorUtility.SetDirty(targetGo);
// Use the new serializer helper // Use the new serializer helper
return Response.Success( return Response.Success(
$"Properties set for component '{compName}' on '{targetGo.name}'.", $"Properties set for component '{compName}' on '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo) Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
@ -1693,8 +1693,8 @@ namespace MCPForUnity.Editor.Tools
BindingFlags flags = BindingFlags flags =
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
// Use shared serializer to avoid per-call allocation // Use shared serializer to avoid per-call allocation
var inputSerializer = InputSerializer; var inputSerializer = InputSerializer;
try try
{ {
@ -1716,8 +1716,9 @@ namespace MCPForUnity.Editor.Tools
propInfo.SetValue(target, convertedValue); propInfo.SetValue(target, convertedValue);
return true; return true;
} }
else { else
Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); {
Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}");
} }
} }
else else
@ -1725,16 +1726,17 @@ namespace MCPForUnity.Editor.Tools
FieldInfo fieldInfo = type.GetField(memberName, flags); FieldInfo fieldInfo = type.GetField(memberName, flags);
if (fieldInfo != null) // Check if !IsLiteral? if (fieldInfo != null) // Check if !IsLiteral?
{ {
// Use the inputSerializer for conversion // Use the inputSerializer for conversion
object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null
{ {
fieldInfo.SetValue(target, convertedValue); fieldInfo.SetValue(target, convertedValue);
return true; return true;
} }
else { else
Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); {
} Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}");
}
} }
else else
{ {
@ -1881,12 +1883,17 @@ namespace MCPForUnity.Editor.Tools
if (value is JArray jArray) if (value is JArray jArray)
{ {
// Try converting to known types that SetColor/SetVector accept // Try converting to known types that SetColor/SetVector accept
if (jArray.Count == 4) { if (jArray.Count == 4)
{
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { }
try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
} else if (jArray.Count == 3) { }
else if (jArray.Count == 3)
{
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color
} else if (jArray.Count == 2) { }
else if (jArray.Count == 2)
{
try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
} }
} }
@ -1901,13 +1908,16 @@ namespace MCPForUnity.Editor.Tools
else if (value.Type == JTokenType.String) else if (value.Type == JTokenType.String)
{ {
// Try converting to Texture using the serializer/converter // Try converting to Texture using the serializer/converter
try { try
{
Texture texture = value.ToObject<Texture>(inputSerializer); Texture texture = value.ToObject<Texture>(inputSerializer);
if (texture != null) { if (texture != null)
{
material.SetTexture(finalPart, texture); material.SetTexture(finalPart, texture);
return true; return true;
} }
} catch { } }
catch { }
} }
Debug.LogWarning( Debug.LogWarning(
@ -1927,7 +1937,8 @@ namespace MCPForUnity.Editor.Tools
finalPropInfo.SetValue(currentObject, convertedValue); finalPropInfo.SetValue(currentObject, convertedValue);
return true; return true;
} }
else { else
{
Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}");
} }
} }
@ -1943,7 +1954,8 @@ namespace MCPForUnity.Editor.Tools
finalFieldInfo.SetValue(currentObject, convertedValue); finalFieldInfo.SetValue(currentObject, convertedValue);
return true; return true;
} }
else { else
{
Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}");
} }
} }
@ -2025,25 +2037,25 @@ namespace MCPForUnity.Editor.Tools
} }
catch (JsonSerializationException jsonEx) catch (JsonSerializationException jsonEx)
{ {
Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}"); Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}");
// Optionally re-throw or return null/default // Optionally re-throw or return null/default
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
throw; // Re-throw to indicate failure higher up throw; // Re-throw to indicate failure higher up
} }
catch (ArgumentException argEx) catch (ArgumentException argEx)
{ {
Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}"); Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}");
throw; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}"); Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}");
throw; throw;
} }
// If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here. // If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here.
// This fallback logic is likely unreachable if ToObject covers all cases or throws on failure. // This fallback logic is likely unreachable if ToObject covers all cases or throws on failure.
// Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}"); // Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}");
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
} }
// --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach ---
@ -2059,7 +2071,7 @@ namespace MCPForUnity.Editor.Tools
} }
if (token is JArray arr && arr.Count >= 3) if (token is JArray arr && arr.Count >= 3)
{ {
return new Vector3(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>()); return new Vector3(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>());
} }
Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero.");
return Vector3.zero; return Vector3.zero;
@ -2068,13 +2080,13 @@ namespace MCPForUnity.Editor.Tools
private static Vector2 ParseJTokenToVector2(JToken token) private static Vector2 ParseJTokenToVector2(JToken token)
{ {
// ... (implementation - likely replaced by Vector2Converter) ... // ... (implementation - likely replaced by Vector2Converter) ...
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y"))
{ {
return new Vector2(obj["x"].ToObject<float>(), obj["y"].ToObject<float>()); return new Vector2(obj["x"].ToObject<float>(), obj["y"].ToObject<float>());
} }
if (token is JArray arr && arr.Count >= 2) if (token is JArray arr && arr.Count >= 2)
{ {
return new Vector2(arr[0].ToObject<float>(), arr[1].ToObject<float>()); return new Vector2(arr[0].ToObject<float>(), arr[1].ToObject<float>());
} }
Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero.");
return Vector2.zero; return Vector2.zero;
@ -2088,47 +2100,47 @@ namespace MCPForUnity.Editor.Tools
} }
if (token is JArray arr && arr.Count >= 4) if (token is JArray arr && arr.Count >= 4)
{ {
return new Quaternion(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); return new Quaternion(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>());
} }
Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity.");
return Quaternion.identity; return Quaternion.identity;
} }
private static Color ParseJTokenToColor(JToken token) private static Color ParseJTokenToColor(JToken token)
{ {
// ... (implementation - likely replaced by ColorConverter) ... // ... (implementation - likely replaced by ColorConverter) ...
if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a"))
{ {
return new Color(obj["r"].ToObject<float>(), obj["g"].ToObject<float>(), obj["b"].ToObject<float>(), obj["a"].ToObject<float>()); return new Color(obj["r"].ToObject<float>(), obj["g"].ToObject<float>(), obj["b"].ToObject<float>(), obj["a"].ToObject<float>());
} }
if (token is JArray arr && arr.Count >= 4) if (token is JArray arr && arr.Count >= 4)
{ {
return new Color(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); return new Color(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>());
} }
Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white.");
return Color.white; return Color.white;
} }
private static Rect ParseJTokenToRect(JToken token) private static Rect ParseJTokenToRect(JToken token)
{ {
// ... (implementation - likely replaced by RectConverter) ... // ... (implementation - likely replaced by RectConverter) ...
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height"))
{ {
return new Rect(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["width"].ToObject<float>(), obj["height"].ToObject<float>()); return new Rect(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["width"].ToObject<float>(), obj["height"].ToObject<float>());
} }
if (token is JArray arr && arr.Count >= 4) if (token is JArray arr && arr.Count >= 4)
{ {
return new Rect(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); return new Rect(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>());
} }
Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero.");
return Rect.zero; return Rect.zero;
} }
private static Bounds ParseJTokenToBounds(JToken token) private static Bounds ParseJTokenToBounds(JToken token)
{ {
// ... (implementation - likely replaced by BoundsConverter) ... // ... (implementation - likely replaced by BoundsConverter) ...
if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size"))
{ {
// Requires Vector3 conversion, which should ideally use the serializer too // Requires Vector3 conversion, which should ideally use the serializer too
Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject<Vector3>(inputSerializer) Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject<Vector3>(inputSerializer)
Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject<Vector3>(inputSerializer) Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject<Vector3>(inputSerializer)
return new Bounds(center, size); return new Bounds(center, size);
} }
// Array fallback for Bounds is less intuitive, maybe remove? // Array fallback for Bounds is less intuitive, maybe remove?
@ -2141,109 +2153,109 @@ namespace MCPForUnity.Editor.Tools
} }
// --- End Redundant Parse Helpers --- // --- End Redundant Parse Helpers ---
/// <summary> /// <summary>
/// Finds a specific UnityEngine.Object based on a find instruction JObject. /// Finds a specific UnityEngine.Object based on a find instruction JObject.
/// Primarily used by UnityEngineObjectConverter during deserialization. /// Primarily used by UnityEngineObjectConverter during deserialization.
/// </summary> /// </summary>
// Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType.
public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType)
{ {
string findTerm = instruction["find"]?.ToString(); string findTerm = instruction["find"]?.ToString();
string method = instruction["method"]?.ToString()?.ToLower(); string method = instruction["method"]?.ToString()?.ToLower();
string componentName = instruction["component"]?.ToString(); // Specific component to get string componentName = instruction["component"]?.ToString(); // Specific component to get
if (string.IsNullOrEmpty(findTerm)) if (string.IsNullOrEmpty(findTerm))
{ {
Debug.LogWarning("Find instruction missing 'find' term."); Debug.LogWarning("Find instruction missing 'find' term.");
return null; return null;
} }
// Use a flexible default search method if none provided // Use a flexible default search method if none provided
string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method;
// If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first
if (typeof(Material).IsAssignableFrom(targetType) || if (typeof(Material).IsAssignableFrom(targetType) ||
typeof(Texture).IsAssignableFrom(targetType) || typeof(Texture).IsAssignableFrom(targetType) ||
typeof(ScriptableObject).IsAssignableFrom(targetType) || typeof(ScriptableObject).IsAssignableFrom(targetType) ||
targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc.
typeof(AudioClip).IsAssignableFrom(targetType) || typeof(AudioClip).IsAssignableFrom(targetType) ||
typeof(AnimationClip).IsAssignableFrom(targetType) || typeof(AnimationClip).IsAssignableFrom(targetType) ||
typeof(Font).IsAssignableFrom(targetType) || typeof(Font).IsAssignableFrom(targetType) ||
typeof(Shader).IsAssignableFrom(targetType) || typeof(Shader).IsAssignableFrom(targetType) ||
typeof(ComputeShader).IsAssignableFrom(targetType) || typeof(ComputeShader).IsAssignableFrom(targetType) ||
typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check
{ {
// Try loading directly by path/GUID first // Try loading directly by path/GUID first
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType);
if (asset != null) return asset; if (asset != null) return asset;
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed
if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset;
// If direct path failed, try finding by name/type using FindAssets // If direct path failed, try finding by name/type using FindAssets
string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name
string[] guids = AssetDatabase.FindAssets(searchFilter); string[] guids = AssetDatabase.FindAssets(searchFilter);
if (guids.Length == 1) if (guids.Length == 1)
{ {
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
if (asset != null) return asset; if (asset != null) return asset;
} }
else if (guids.Length > 1) else if (guids.Length > 1)
{ {
Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name.");
// Optionally return the first one? Or null? Returning null is safer. // Optionally return the first one? Or null? Returning null is safer.
return null; return null;
} }
// If still not found, fall through to scene search (though unlikely for assets) // If still not found, fall through to scene search (though unlikely for assets)
} }
// --- Scene Object Search --- // --- Scene Object Search ---
// Find the GameObject using the internal finder // Find the GameObject using the internal finder
GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse);
if (foundGo == null) if (foundGo == null)
{ {
// Don't warn yet, could still be an asset not found above // Don't warn yet, could still be an asset not found above
// Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}");
return null; return null;
} }
// Now, get the target object/component from the found GameObject // Now, get the target object/component from the found GameObject
if (targetType == typeof(GameObject)) if (targetType == typeof(GameObject))
{ {
return foundGo; // We were looking for a GameObject return foundGo; // We were looking for a GameObject
} }
else if (typeof(Component).IsAssignableFrom(targetType)) else if (typeof(Component).IsAssignableFrom(targetType))
{ {
Type componentToGetType = targetType; Type componentToGetType = targetType;
if (!string.IsNullOrEmpty(componentName)) if (!string.IsNullOrEmpty(componentName))
{ {
Type specificCompType = FindType(componentName); Type specificCompType = FindType(componentName);
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
{ {
componentToGetType = specificCompType; componentToGetType = specificCompType;
} }
else else
{ {
Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'.");
} }
} }
Component foundComp = foundGo.GetComponent(componentToGetType); Component foundComp = foundGo.GetComponent(componentToGetType);
if (foundComp == null) if (foundComp == null)
{ {
Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
} }
return foundComp; return foundComp;
} }
else else
{ {
Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}");
return null; return null;
} }
} }
/// <summary> /// <summary>
@ -2256,17 +2268,17 @@ namespace MCPForUnity.Editor.Tools
{ {
return resolvedType; return resolvedType;
} }
// Log the resolver error if type wasn't found // Log the resolver error if type wasn't found
if (!string.IsNullOrEmpty(error)) if (!string.IsNullOrEmpty(error))
{ {
Debug.LogWarning($"[FindType] {error}"); Debug.LogWarning($"[FindType] {error}");
} }
return null; return null;
} }
} }
/// <summary> /// <summary>
/// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions.
/// Prioritizes runtime (Player) assemblies over Editor assemblies. /// Prioritizes runtime (Player) assemblies over Editor assemblies.
@ -2445,7 +2457,7 @@ namespace MCPForUnity.Editor.Tools
// For now, we'll use a simple rule-based approach that mimics AI behavior // For now, we'll use a simple rule-based approach that mimics AI behavior
// This can be replaced with actual AI calls later // This can be replaced with actual AI calls later
var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); var suggestions = GetRuleBasedSuggestions(userInput, availableProperties);
PropertySuggestionCache[cacheKey] = suggestions; PropertySuggestionCache[cacheKey] = suggestions;
return suggestions; return suggestions;
} }
@ -2470,7 +2482,7 @@ namespace MCPForUnity.Editor.Tools
foreach (var property in availableProperties) foreach (var property in availableProperties)
{ {
var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
// Exact match after cleaning // Exact match after cleaning
if (cleanedProperty == cleanedInput) if (cleanedProperty == cleanedInput)
{ {
@ -2533,4 +2545,3 @@ namespace MCPForUnity.Editor.Tools
// They are now in Helpers.GameObjectSerializer // They are now in Helpers.GameObjectSerializer
} }
} }

View File

@ -472,4 +472,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -2658,4 +2658,3 @@ static class ManageScriptRefreshHelpers
#endif #endif
} }
} }

View File

@ -339,4 +339,4 @@ namespace MCPForUnity.Editor.Tools
}"; }";
} }
} }
} }

View File

@ -43,8 +43,8 @@ namespace MCPForUnity.Editor.Tools
); );
if (logEntriesType == null) if (logEntriesType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntries"); throw new Exception("Could not find internal type UnityEditor.LogEntries");
// Include NonPublic binding flags as internal APIs might change accessibility // Include NonPublic binding flags as internal APIs might change accessibility
BindingFlags staticFlags = BindingFlags staticFlags =
@ -104,9 +104,9 @@ namespace MCPForUnity.Editor.Tools
_instanceIdField = logEntryType.GetField("instanceID", instanceFlags); _instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
if (_instanceIdField == null) if (_instanceIdField == null)
throw new Exception("Failed to reflect LogEntry.instanceID"); throw new Exception("Failed to reflect LogEntry.instanceID");
// (Calibration removed) // (Calibration removed)
} }
catch (Exception e) catch (Exception e)
{ {
@ -505,7 +505,7 @@ namespace MCPForUnity.Editor.Tools
|| trimmedLine.StartsWith("UnityEditor.") || trimmedLine.StartsWith("UnityEditor.")
|| trimmedLine.Contains("(at ") || trimmedLine.Contains("(at ")
|| // Covers "(at Assets/..." pattern || // Covers "(at Assets/..." pattern
// Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
( (
trimmedLine.Length > 0 trimmedLine.Length > 0
&& char.IsUpper(trimmedLine[0]) && char.IsUpper(trimmedLine[0])
@ -568,4 +568,3 @@ namespace MCPForUnity.Editor.Tools
*/ */
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -13,14 +13,14 @@ namespace MCPForUnity.Editor.Windows
window.configPath = configPath; window.configPath = configPath;
window.configJson = configJson; window.configJson = configJson;
window.minSize = new Vector2(550, 500); window.minSize = new Vector2(550, 500);
// Create a McpClient for VSCode // Create a McpClient for VSCode
window.mcpClient = new McpClient window.mcpClient = new McpClient
{ {
name = "VSCode GitHub Copilot", name = "VSCode GitHub Copilot",
mcpType = McpTypes.VSCode mcpType = McpTypes.VSCode
}; };
window.Show(); window.Show();
} }
@ -84,7 +84,7 @@ namespace MCPForUnity.Editor.Windows
instructionStyle instructionStyle
); );
EditorGUILayout.Space(5); EditorGUILayout.Space(5);
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
"2. Steps to Configure", "2. Steps to Configure",
EditorStyles.boldLabel EditorStyles.boldLabel
@ -102,7 +102,7 @@ namespace MCPForUnity.Editor.Windows
instructionStyle instructionStyle
); );
EditorGUILayout.Space(5); EditorGUILayout.Space(5);
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
"3. VSCode mcp.json location:", "3. VSCode mcp.json location:",
EditorStyles.boldLabel EditorStyles.boldLabel
@ -120,7 +120,7 @@ namespace MCPForUnity.Editor.Windows
"mcp.json" "mcp.json"
); );
} }
else else
{ {
displayPath = System.IO.Path.Combine( displayPath = System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),

View File

@ -110,7 +110,7 @@ namespace MCPForUnity.Runtime.Serialization
); );
} }
} }
public class RectConverter : JsonConverter<Rect> public class RectConverter : JsonConverter<Rect>
{ {
public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer)
@ -138,7 +138,7 @@ namespace MCPForUnity.Runtime.Serialization
); );
} }
} }
public class BoundsConverter : JsonConverter<Bounds> public class BoundsConverter : JsonConverter<Bounds>
{ {
public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer)
@ -263,4 +263,4 @@ namespace MCPForUnity.Runtime.Serialization
throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object");
} }
} }
} }

View File

@ -1,3 +1,4 @@
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
import logging import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
@ -21,10 +22,12 @@ logger = logging.getLogger("mcp-for-unity-server")
# Also write logs to a rotating file so logs are available when launched via stdio # Also write logs to a rotating file so logs are available when launched via stdio
try: try:
import os as _os import os as _os
_log_dir = _os.path.join(_os.path.expanduser("~/Library/Application Support/UnityMCP"), "Logs") _log_dir = _os.path.join(_os.path.expanduser(
"~/Library/Application Support/UnityMCP"), "Logs")
_os.makedirs(_log_dir, exist_ok=True) _os.makedirs(_log_dir, exist_ok=True)
_file_path = _os.path.join(_log_dir, "unity_mcp_server.log") _file_path = _os.path.join(_log_dir, "unity_mcp_server.log")
_fh = RotatingFileHandler(_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") _fh = RotatingFileHandler(
_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
_fh.setFormatter(logging.Formatter(config.log_format)) _fh.setFormatter(logging.Formatter(config.log_format))
_fh.setLevel(getattr(logging, config.log_level)) _fh.setLevel(getattr(logging, config.log_level))
logger.addHandler(_fh) logger.addHandler(_fh)
@ -42,7 +45,8 @@ except Exception:
# Quieten noisy third-party loggers to avoid clutter during stdio handshake # Quieten noisy third-party loggers to avoid clutter during stdio handshake
for noisy in ("httpx", "urllib3"): for noisy in ("httpx", "urllib3"):
try: try:
logging.getLogger(noisy).setLevel(max(logging.WARNING, getattr(logging, config.log_level))) logging.getLogger(noisy).setLevel(
max(logging.WARNING, getattr(logging, config.log_level)))
except Exception: except Exception:
pass pass
@ -50,13 +54,11 @@ for noisy in ("httpx", "urllib3"):
# Ensure a slightly higher telemetry timeout unless explicitly overridden by env # Ensure a slightly higher telemetry timeout unless explicitly overridden by env
try: try:
# Ensure generous timeout unless explicitly overridden by env # Ensure generous timeout unless explicitly overridden by env
if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"):
os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0"
except Exception: except Exception:
pass pass
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
# Global connection state # Global connection state
_unity_connection: UnityConnection = None _unity_connection: UnityConnection = None
@ -67,7 +69,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"""Handle server startup and shutdown.""" """Handle server startup and shutdown."""
global _unity_connection global _unity_connection
logger.info("MCP for Unity Server starting up") logger.info("MCP for Unity Server starting up")
# Record server startup telemetry # Record server startup telemetry
start_time = time.time() start_time = time.time()
start_clk = time.perf_counter() start_clk = time.perf_counter()
@ -79,6 +81,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
server_version = "unknown" server_version = "unknown"
# Defer initial telemetry by 1s to avoid stdio handshake interference # Defer initial telemetry by 1s to avoid stdio handshake interference
import threading import threading
def _emit_startup(): def _emit_startup():
try: try:
record_telemetry(RecordType.STARTUP, { record_telemetry(RecordType.STARTUP, {
@ -89,15 +92,17 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
except Exception: except Exception:
logger.debug("Deferred startup telemetry failed", exc_info=True) logger.debug("Deferred startup telemetry failed", exc_info=True)
threading.Timer(1.0, _emit_startup).start() threading.Timer(1.0, _emit_startup).start()
try: try:
skip_connect = os.environ.get("UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") skip_connect = os.environ.get(
"UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on")
if skip_connect: if skip_connect:
logger.info("Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") logger.info(
"Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
else: else:
_unity_connection = get_unity_connection() _unity_connection = get_unity_connection()
logger.info("Connected to Unity on startup") logger.info("Connected to Unity on startup")
# Record successful Unity connection (deferred) # Record successful Unity connection (deferred)
import threading as _t import threading as _t
_t.Timer(1.0, lambda: record_telemetry( _t.Timer(1.0, lambda: record_telemetry(
@ -107,11 +112,11 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"connection_time_ms": (time.perf_counter() - start_clk) * 1000, "connection_time_ms": (time.perf_counter() - start_clk) * 1000,
} }
)).start() )).start()
except ConnectionError as e: except ConnectionError as e:
logger.warning("Could not connect to Unity on startup: %s", e) logger.warning("Could not connect to Unity on startup: %s", e)
_unity_connection = None _unity_connection = None
# Record connection failure (deferred) # Record connection failure (deferred)
import threading as _t import threading as _t
_err_msg = str(e)[:200] _err_msg = str(e)[:200]
@ -124,7 +129,8 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
} }
)).start() )).start()
except Exception as e: except Exception as e:
logger.warning("Unexpected error connecting to Unity on startup: %s", e) logger.warning(
"Unexpected error connecting to Unity on startup: %s", e)
_unity_connection = None _unity_connection = None
import threading as _t import threading as _t
_err_msg = str(e)[:200] _err_msg = str(e)[:200]
@ -136,7 +142,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"connection_time_ms": (time.perf_counter() - start_clk) * 1000, "connection_time_ms": (time.perf_counter() - start_clk) * 1000,
} }
)).start() )).start()
try: try:
# Yield the connection object so it can be attached to the context # Yield the connection object so it can be attached to the context
# The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge)

View File

@ -32,7 +32,8 @@ def run_git(repo: pathlib.Path, *args: str) -> str:
"git", "-C", str(repo), *args "git", "-C", str(repo), *args
], capture_output=True, text=True) ], capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") raise RuntimeError(result.stderr.strip()
or f"git {' '.join(args)} failed")
return result.stdout.strip() return result.stdout.strip()
@ -77,7 +78,8 @@ def find_manifest(explicit: Optional[str]) -> pathlib.Path:
candidate = parent / "Packages" / "manifest.json" candidate = parent / "Packages" / "manifest.json"
if candidate.exists(): if candidate.exists():
return candidate return candidate
raise FileNotFoundError("Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.") raise FileNotFoundError(
"Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.")
def read_json(path: pathlib.Path) -> dict: def read_json(path: pathlib.Path) -> dict:
@ -103,16 +105,21 @@ def build_options(repo_root: pathlib.Path, branch: str, origin_https: str):
origin_remote = origin origin_remote = origin
return [ return [
("[1] Upstream main", upstream), ("[1] Upstream main", upstream),
("[2] Remote current branch", f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"), ("[2] Remote current branch",
("[3] Local workspace", f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"), f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"),
("[3] Local workspace",
f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"),
] ]
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Switch MCP for Unity package source") p = argparse.ArgumentParser(
description="Switch MCP for Unity package source")
p.add_argument("--manifest", help="Path to Packages/manifest.json") p.add_argument("--manifest", help="Path to Packages/manifest.json")
p.add_argument("--repo", help="Path to unity-mcp repo root (for local file option)") p.add_argument(
p.add_argument("--choice", choices=["1", "2", "3"], help="Pick option non-interactively") "--repo", help="Path to unity-mcp repo root (for local file option)")
p.add_argument(
"--choice", choices=["1", "2", "3"], help="Pick option non-interactively")
return p.parse_args() return p.parse_args()
@ -153,7 +160,8 @@ def main() -> None:
data = read_json(manifest_path) data = read_json(manifest_path)
deps = data.get("dependencies", {}) deps = data.get("dependencies", {})
if PKG_NAME not in deps: if PKG_NAME not in deps:
print(f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) print(
f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr)
sys.exit(1) sys.exit(1)
print(f"\nUpdating {PKG_NAME}{chosen}") print(f"\nUpdating {PKG_NAME}{chosen}")

View File

@ -1,5 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import socket, struct, json, sys import socket
import struct
import json
import sys
HOST = "127.0.0.1" HOST = "127.0.0.1"
PORT = 6400 PORT = 6400
@ -10,6 +13,7 @@ except (IndexError, ValueError):
FILL = "R" FILL = "R"
MAX_FRAME = 64 * 1024 * 1024 MAX_FRAME = 64 * 1024 * 1024
def recv_exact(sock, n): def recv_exact(sock, n):
buf = bytearray(n) buf = bytearray(n)
view = memoryview(buf) view = memoryview(buf)
@ -21,6 +25,7 @@ def recv_exact(sock, n):
off += r off += r
return bytes(buf) return bytes(buf)
def is_valid_json(b): def is_valid_json(b):
try: try:
json.loads(b.decode("utf-8")) json.loads(b.decode("utf-8"))
@ -28,6 +33,7 @@ def is_valid_json(b):
except Exception: except Exception:
return False return False
def recv_legacy_json(sock, timeout=60): def recv_legacy_json(sock, timeout=60):
sock.settimeout(timeout) sock.settimeout(timeout)
chunks = [] chunks = []
@ -45,6 +51,7 @@ def recv_legacy_json(sock, timeout=60):
if is_valid_json(data): if is_valid_json(data):
return data return data
def main(): def main():
# Cap filler to stay within framing limit (reserve small overhead for JSON) # Cap filler to stay within framing limit (reserve small overhead for JSON)
safe_max = max(1, MAX_FRAME - 4096) safe_max = max(1, MAX_FRAME - 4096)
@ -83,16 +90,16 @@ def main():
print(f"Response framed length: {resp_len}") print(f"Response framed length: {resp_len}")
MAX_RESP = MAX_FRAME MAX_RESP = MAX_FRAME
if resp_len <= 0 or resp_len > MAX_RESP: if resp_len <= 0 or resp_len > MAX_RESP:
raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})") raise RuntimeError(
f"invalid framed length: {resp_len} (max {MAX_RESP})")
resp = recv_exact(s, resp_len) resp = recv_exact(s, resp_len)
else: else:
s.sendall(body_bytes) s.sendall(body_bytes)
resp = recv_legacy_json(s) resp = recv_legacy_json(s)
print(f"Response bytes: {len(resp)}") print(f"Response bytes: {len(resp)}")
print(f"Response head: {resp[:120].decode('utf-8','ignore')}") print(f"Response head: {resp[:120].decode('utf-8', 'ignore')}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -5,4 +5,3 @@ import os
os.environ.setdefault("DISABLE_TELEMETRY", "true") os.environ.setdefault("DISABLE_TELEMETRY", "true")
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true") os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true") os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")

View File

@ -12,7 +12,12 @@ sys.path.insert(0, str(SRC))
mcp_pkg = types.ModuleType("mcp") mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: pass
class _Dummy:
pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -21,22 +26,27 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load(path: pathlib.Path, name: str): def _load(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
return mod return mod
manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2")
manage_script_edits = _load(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") manage_script_edits = _load(
SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2")
class DummyMCP: class DummyMCP:
def __init__(self): self.tools = {} def __init__(self): self.tools = {}
def tool(self, *args, **kwargs): def tool(self, *args, **kwargs):
def deco(fn): self.tools[fn.__name__] = fn; return fn def deco(fn): self.tools[fn.__name__] = fn; return fn
return deco return deco
def setup_tools(): def setup_tools():
mcp = DummyMCP() mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp) manage_script.register_manage_script_tools(mcp)
@ -59,7 +69,8 @@ def test_normalizes_lsp_and_index_ranges(monkeypatch):
"range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}}, "range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}},
"newText": "// lsp\n" "newText": "// lsp\n"
}] }]
apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") apply(None, uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="x")
p = calls[-1] p = calls[-1]
e = p["edits"][0] e = p["edits"][0]
assert e["startLine"] == 11 and e["startCol"] == 3 assert e["startLine"] == 11 and e["startCol"] == 3
@ -68,24 +79,28 @@ def test_normalizes_lsp_and_index_ranges(monkeypatch):
calls.clear() calls.clear()
edits = [{"range": [0, 0], "text": "// idx\n"}] edits = [{"range": [0, 0], "text": "// idx\n"}]
# fake read to provide contents length # fake read to provide contents length
def fake_read(cmd, params): def fake_read(cmd, params):
if params.get("action") == "read": if params.get("action") == "read":
return {"success": True, "data": {"contents": "hello\n"}} return {"success": True, "data": {"contents": "hello\n"}}
return {"success": True} return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read) monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read)
apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") apply(None, uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="x")
# last call is apply_text_edits # last call is apply_text_edits
def test_noop_evidence_shape(monkeypatch): def test_noop_evidence_shape(monkeypatch):
tools = setup_tools() tools = setup_tools()
apply = tools["apply_text_edits"] apply = tools["apply_text_edits"]
# Route response from Unity indicating no-op # Route response from Unity indicating no-op
def fake_send(cmd, params): def fake_send(cmd, params):
return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}} return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":""}], precondition_sha256="x") resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[
{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x")
assert resp["success"] is True assert resp["success"] is True
assert resp.get("data", {}).get("no_op") is True assert resp.get("data", {}).get("no_op") is True
@ -93,9 +108,11 @@ def test_noop_evidence_shape(monkeypatch):
def test_atomic_multi_span_and_relaxed(monkeypatch): def test_atomic_multi_span_and_relaxed(monkeypatch):
tools_text = setup_tools() tools_text = setup_tools()
apply_text = tools_text["apply_text_edits"] apply_text = tools_text["apply_text_edits"]
tools_struct = DummyMCP(); manage_script_edits.register_manage_script_edits_tools(tools_struct) tools_struct = DummyMCP()
manage_script_edits.register_manage_script_edits_tools(tools_struct)
# Fake send for read and write; verify atomic applyMode and validate=relaxed passes through # Fake send for read and write; verify atomic applyMode and validate=relaxed passes through
sent = {} sent = {}
def fake_send(cmd, params): def fake_send(cmd, params):
if params.get("action") == "read": if params.get("action") == "read":
return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}} return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}}
@ -105,12 +122,13 @@ def test_atomic_multi_span_and_relaxed(monkeypatch):
edits = [ edits = [
{"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"}, {"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"},
{"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"} {"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"}
] ]
resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits,
precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"})
assert resp["success"] is True assert resp["success"] is True
# Last manage_script call should include options with applyMode atomic and validate relaxed # Last manage_script call should include options with applyMode atomic and validate relaxed
last = sent["calls"][-1] last = sent["calls"][-1]
assert last.get("options", {}).get("applyMode") == "atomic" assert last.get("options", {}).get("applyMode") == "atomic"
assert last.get("options", {}).get("validate") == "relaxed" assert last.get("options", {}).get("validate") == "relaxed"

View File

@ -12,7 +12,12 @@ sys.path.insert(0, str(SRC))
mcp_pkg = types.ModuleType("mcp") mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: pass
class _Dummy:
pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -34,6 +39,7 @@ manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3")
class DummyMCP: class DummyMCP:
def __init__(self): self.tools = {} def __init__(self): self.tools = {}
def tool(self, *args, **kwargs): def tool(self, *args, **kwargs):
def deco(fn): self.tools[fn.__name__] = fn; return fn def deco(fn): self.tools[fn.__name__] = fn; return fn
return deco return deco
@ -56,13 +62,16 @@ def test_explicit_zero_based_normalized_warning(monkeypatch):
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Explicit fields given as 0-based (invalid); SDK should normalize and warn # Explicit fields given as 0-based (invalid); SDK should normalize and warn
edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] edits = [{"startLine": 0, "startCol": 0,
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha") "endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="sha")
assert resp["success"] is True assert resp["success"] is True
data = resp.get("data", {}) data = resp.get("data", {})
assert "normalizedEdits" in data assert "normalizedEdits" in data
assert any(w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", [])) assert any(
w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", []))
ne = data["normalizedEdits"][0] ne = data["normalizedEdits"][0]
assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1 assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1
@ -76,9 +85,9 @@ def test_strict_zero_based_error(monkeypatch):
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] edits = [{"startLine": 0, "startCol": 0,
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True) "endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="sha", strict=True)
assert resp["success"] is False assert resp["success"] is False
assert resp.get("code") == "zero_based_explicit_fields" assert resp.get("code") == "zero_based_explicit_fields"

View File

@ -1,3 +1,4 @@
from tools.resource_tools import register_resource_tools # type: ignore
import sys import sys
import pathlib import pathlib
import importlib.util import importlib.util
@ -9,7 +10,6 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC)) sys.path.insert(0, str(SRC))
from tools.resource_tools import register_resource_tools # type: ignore
class DummyMCP: class DummyMCP:
def __init__(self): def __init__(self):
@ -21,12 +21,14 @@ class DummyMCP:
return fn return fn
return deco return deco
@pytest.fixture() @pytest.fixture()
def resource_tools(): def resource_tools():
mcp = DummyMCP() mcp = DummyMCP()
register_resource_tools(mcp) register_resource_tools(mcp)
return mcp.tools return mcp.tools
def test_find_in_file_returns_positions(resource_tools, tmp_path): def test_find_in_file_returns_positions(resource_tools, tmp_path):
proj = tmp_path proj = tmp_path
assets = proj / "Assets" assets = proj / "Assets"
@ -37,9 +39,11 @@ def test_find_in_file_returns_positions(resource_tools, tmp_path):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
try: try:
resp = loop.run_until_complete( resp = loop.run_until_complete(
find_in_file(uri="unity://path/Assets/A.txt", pattern="world", ctx=None, project_root=str(proj)) find_in_file(uri="unity://path/Assets/A.txt",
pattern="world", ctx=None, project_root=str(proj))
) )
finally: finally:
loop.close() loop.close()
assert resp["success"] is True assert resp["success"] is True
assert resp["data"]["matches"] == [{"startLine": 1, "startCol": 7, "endLine": 1, "endCol": 12}] assert resp["data"]["matches"] == [
{"startLine": 1, "startCol": 7, "endLine": 1, "endCol": 12}]

View File

@ -13,9 +13,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -32,7 +34,8 @@ def _load_module(path: pathlib.Path, name: str):
return mod return mod
manage_script = _load_module(SRC / "tools" / "manage_script.py", "manage_script_mod") manage_script = _load_module(
SRC / "tools" / "manage_script.py", "manage_script_mod")
class DummyMCP: class DummyMCP:
@ -72,4 +75,3 @@ def test_get_sha_param_shape_and_routing(monkeypatch):
assert captured["params"]["path"].endswith("Assets/Scripts") assert captured["params"]["path"].endswith("Assets/Scripts")
assert resp["success"] is True assert resp["success"] is True
assert resp["data"] == {"sha256": "abc", "lengthBytes": 1} assert resp["data"] == {"sha256": "abc", "lengthBytes": 1}

View File

@ -17,9 +17,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -28,17 +30,21 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def load_module(path, name): def load_module(path, name):
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
return module return module
manage_script_edits_module = load_module(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module")
manage_script_edits_module = load_module(
SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module")
def test_improved_anchor_matching(): def test_improved_anchor_matching():
"""Test that our improved anchor matching finds the right closing brace.""" """Test that our improved anchor matching finds the right closing brace."""
test_code = '''using UnityEngine; test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour public class TestClass : MonoBehaviour
@ -53,27 +59,29 @@ public class TestClass : MonoBehaviour
// Update logic // Update logic
} }
}''' }'''
import re import re
# Test the problematic anchor pattern # Test the problematic anchor pattern
anchor_pattern = r"\s*}\s*$" anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE flags = re.MULTILINE
# Test our improved function # Test our improved function
best_match = manage_script_edits_module._find_best_anchor_match( best_match = manage_script_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True anchor_pattern, test_code, flags, prefer_last=True
) )
assert best_match is not None, "anchor pattern not found" assert best_match is not None, "anchor pattern not found"
match_pos = best_match.start() match_pos = best_match.start()
line_num = test_code[:match_pos].count('\n') + 1 line_num = test_code[:match_pos].count('\n') + 1
total_lines = test_code.count('\n') + 1 total_lines = test_code.count('\n') + 1
assert line_num >= total_lines - 2, f"expected match near end (>= {total_lines-2}), got line {line_num}" assert line_num >= total_lines - \
2, f"expected match near end (>= {total_lines-2}), got line {line_num}"
def test_old_vs_new_matching(): def test_old_vs_new_matching():
"""Compare old vs new matching behavior.""" """Compare old vs new matching behavior."""
test_code = '''using UnityEngine; test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour public class TestClass : MonoBehaviour
@ -96,30 +104,34 @@ public class TestClass : MonoBehaviour
// More logic // More logic
} }
}''' }'''
import re import re
anchor_pattern = r"\s*}\s*$" anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE flags = re.MULTILINE
# Old behavior (first match) # Old behavior (first match)
old_match = re.search(anchor_pattern, test_code, flags) old_match = re.search(anchor_pattern, test_code, flags)
old_line = test_code[:old_match.start()].count('\n') + 1 if old_match else None old_line = test_code[:old_match.start()].count(
'\n') + 1 if old_match else None
# New behavior (improved matching) # New behavior (improved matching)
new_match = manage_script_edits_module._find_best_anchor_match( new_match = manage_script_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True anchor_pattern, test_code, flags, prefer_last=True
) )
new_line = test_code[:new_match.start()].count('\n') + 1 if new_match else None new_line = test_code[:new_match.start()].count(
'\n') + 1 if new_match else None
assert old_line is not None and new_line is not None, "failed to locate anchors" assert old_line is not None and new_line is not None, "failed to locate anchors"
assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})" assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})"
total_lines = test_code.count('\n') + 1 total_lines = test_code.count('\n') + 1
assert new_line >= total_lines - 2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}" assert new_line >= total_lines - \
2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}"
def test_apply_edits_with_improved_matching(): def test_apply_edits_with_improved_matching():
"""Test that _apply_edits_locally uses improved matching.""" """Test that _apply_edits_locally uses improved matching."""
original_code = '''using UnityEngine; original_code = '''using UnityEngine;
public class TestClass : MonoBehaviour public class TestClass : MonoBehaviour
@ -131,7 +143,7 @@ public class TestClass : MonoBehaviour
Debug.Log(message); Debug.Log(message);
} }
}''' }'''
# Test anchor_insert with the problematic pattern # Test anchor_insert with the problematic pattern
edits = [{ edits = [{
"op": "anchor_insert", "op": "anchor_insert",
@ -139,30 +151,33 @@ public class TestClass : MonoBehaviour
"position": "before", "position": "before",
"text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n" "text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n"
}] }]
result = manage_script_edits_module._apply_edits_locally(original_code, edits) result = manage_script_edits_module._apply_edits_locally(
original_code, edits)
lines = result.split('\n') lines = result.split('\n')
try: try:
idx = next(i for i, line in enumerate(lines) if "NewMethod" in line) idx = next(i for i, line in enumerate(lines) if "NewMethod" in line)
except StopIteration: except StopIteration:
assert False, "NewMethod not found in result" assert False, "NewMethod not found in result"
total_lines = len(lines) total_lines = len(lines)
assert idx >= total_lines - 5, f"method inserted too early (idx={idx}, total_lines={total_lines})" assert idx >= total_lines - \
5, f"method inserted too early (idx={idx}, total_lines={total_lines})"
if __name__ == "__main__": if __name__ == "__main__":
print("Testing improved anchor matching...") print("Testing improved anchor matching...")
print("="*60) print("="*60)
success1 = test_improved_anchor_matching() success1 = test_improved_anchor_matching()
print("\n" + "="*60) print("\n" + "="*60)
print("Comparing old vs new behavior...") print("Comparing old vs new behavior...")
success2 = test_old_vs_new_matching() success2 = test_old_vs_new_matching()
print("\n" + "="*60) print("\n" + "="*60)
print("Testing _apply_edits_locally with improved matching...") print("Testing _apply_edits_locally with improved matching...")
success3 = test_apply_edits_with_improved_matching() success3 = test_apply_edits_with_improved_matching()
print("\n" + "="*60) print("\n" + "="*60)
if success1 and success2 and success3: if success1 and success2 and success3:
print("🎉 ALL TESTS PASSED! Improved anchor matching is working!") print("🎉 ALL TESTS PASSED! Improved anchor matching is working!")

View File

@ -64,5 +64,7 @@ def test_no_print_statements_in_codebase():
v.visit(tree) v.visit(tree)
if v.hit: if v.hit:
offenders.append(py_file.relative_to(SRC)) offenders.append(py_file.relative_to(SRC))
assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors) assert not syntax_errors, "syntax errors in: " + \
assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) ", ".join(str(e) for e in syntax_errors)
assert not offenders, "stdout writes found in: " + \
", ".join(str(o) for o in offenders)

View File

@ -1,3 +1,4 @@
import tools.manage_script as manage_script # type: ignore
import sys import sys
import types import types
from pathlib import Path from pathlib import Path
@ -5,7 +6,6 @@ from pathlib import Path
import pytest import pytest
# Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) # Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests)
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
candidates = [ candidates = [
@ -25,7 +25,12 @@ sys.path.insert(0, str(SRC))
mcp_pkg = types.ModuleType("mcp") mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: pass
class _Dummy:
pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -36,7 +41,6 @@ sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
# Import target module after path injection # Import target module after path injection
import tools.manage_script as manage_script # type: ignore
class DummyMCP: class DummyMCP:
@ -83,10 +87,13 @@ def test_split_uri_unity_path(monkeypatch):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"uri, expected_name, expected_path", "uri, expected_name, expected_path",
[ [
("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", "Foo Bar", "Assets/Scripts"), ("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs",
"Foo Bar", "Assets/Scripts"),
("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"), ("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"),
("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", "Hello", "Assets/Scripts"), ("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs",
("file:///tmp/Other.cs", "Other", "tmp"), # outside Assets → fall back to normalized dir "Hello", "Assets/Scripts"),
# outside Assets → fall back to normalized dir
("file:///tmp/Other.cs", "Other", "tmp"),
], ],
) )
def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path):
@ -118,9 +125,8 @@ def test_split_uri_plain_path(monkeypatch):
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
fn = tools['apply_text_edits'] fn = tools['apply_text_edits']
fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", edits=[], precondition_sha256=None) fn(DummyCtx(), uri="Assets/Scripts/Thing.cs",
edits=[], precondition_sha256=None)
assert captured['params']['name'] == 'Thing' assert captured['params']['name'] == 'Thing'
assert captured['params']['path'] == 'Assets/Scripts' assert captured['params']['path'] == 'Assets/Scripts'

View File

@ -12,9 +12,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -23,13 +25,17 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load_module(path: pathlib.Path, name: str): def _load_module(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
return mod return mod
read_console_mod = _load_module(SRC / "tools" / "read_console.py", "read_console_mod")
read_console_mod = _load_module(
SRC / "tools" / "read_console.py", "read_console_mod")
class DummyMCP: class DummyMCP:
def __init__(self): def __init__(self):
@ -41,11 +47,13 @@ class DummyMCP:
return fn return fn
return deco return deco
def setup_tools(): def setup_tools():
mcp = DummyMCP() mcp = DummyMCP()
read_console_mod.register_read_console_tools(mcp) read_console_mod.register_read_console_tools(mcp)
return mcp.tools return mcp.tools
def test_read_console_full_default(monkeypatch): def test_read_console_full_default(monkeypatch):
tools = setup_tools() tools = setup_tools()
read_console = tools["read_console"] read_console = tools["read_console"]
@ -60,7 +68,8 @@ def test_read_console_full_default(monkeypatch):
} }
monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send)
monkeypatch.setattr(read_console_mod, "get_unity_connection", lambda: object()) monkeypatch.setattr(
read_console_mod, "get_unity_connection", lambda: object())
resp = read_console(ctx=None, count=10) resp = read_console(ctx=None, count=10)
assert resp == { assert resp == {
@ -85,8 +94,10 @@ def test_read_console_truncated(monkeypatch):
} }
monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send)
monkeypatch.setattr(read_console_mod, "get_unity_connection", lambda: object()) monkeypatch.setattr(
read_console_mod, "get_unity_connection", lambda: object())
resp = read_console(ctx=None, count=10, include_stacktrace=False) resp = read_console(ctx=None, count=10, include_stacktrace=False)
assert resp == {"success": True, "data": {"lines": [{"level": "error", "message": "oops"}]}} assert resp == {"success": True, "data": {
"lines": [{"level": "error", "message": "oops"}]}}
assert captured["params"]["includeStacktrace"] is False assert captured["params"]["includeStacktrace"] is False

View File

@ -1,3 +1,4 @@
from tools.resource_tools import register_resource_tools # type: ignore
import sys import sys
import pathlib import pathlib
import asyncio import asyncio
@ -13,9 +14,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -24,8 +27,6 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
from tools.resource_tools import register_resource_tools # type: ignore
class DummyMCP: class DummyMCP:
def __init__(self): def __init__(self):
@ -57,7 +58,8 @@ def test_read_resource_minimal_metadata_only(resource_tools, tmp_path):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
try: try:
resp = loop.run_until_complete( resp = loop.run_until_complete(
read_resource(uri="unity://path/Assets/A.txt", ctx=None, project_root=str(proj)) read_resource(uri="unity://path/Assets/A.txt",
ctx=None, project_root=str(proj))
) )
finally: finally:
loop.close() loop.close()

View File

@ -1,3 +1,4 @@
from tools.resource_tools import register_resource_tools # type: ignore
import pytest import pytest
@ -21,17 +22,18 @@ if SRC is None:
) )
sys.path.insert(0, str(SRC)) sys.path.insert(0, str(SRC))
from tools.resource_tools import register_resource_tools # type: ignore
class DummyMCP: class DummyMCP:
def __init__(self): def __init__(self):
self._tools = {} self._tools = {}
def tool(self, *args, **kwargs): # accept kwargs like description def tool(self, *args, **kwargs): # accept kwargs like description
def deco(fn): def deco(fn):
self._tools[fn.__name__] = fn self._tools[fn.__name__] = fn
return fn return fn
return deco return deco
@pytest.fixture() @pytest.fixture()
def resource_tools(): def resource_tools():
mcp = DummyMCP() mcp = DummyMCP()
@ -60,7 +62,8 @@ def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, m
# Only .cs under Assets should be listed # Only .cs under Assets should be listed
import asyncio import asyncio
resp = asyncio.get_event_loop().run_until_complete( resp = asyncio.get_event_loop().run_until_complete(
list_resources(ctx=None, pattern="*.cs", under="Assets", limit=50, project_root=str(proj)) list_resources(ctx=None, pattern="*.cs", under="Assets",
limit=50, project_root=str(proj))
) )
assert resp["success"] is True assert resp["success"] is True
uris = resp["data"]["uris"] uris = resp["data"]["uris"]
@ -75,7 +78,9 @@ def test_resource_list_rejects_outside_paths(resource_tools, tmp_path):
list_resources = resource_tools["list_resources"] list_resources = resource_tools["list_resources"]
import asyncio import asyncio
resp = asyncio.get_event_loop().run_until_complete( resp = asyncio.get_event_loop().run_until_complete(
list_resources(ctx=None, pattern="*.cs", under="..", limit=10, project_root=str(proj)) list_resources(ctx=None, pattern="*.cs", under="..",
limit=10, project_root=str(proj))
) )
assert resp["success"] is False assert resp["success"] is False
assert "Assets" in resp.get("error", "") or "under project root" in resp.get("error", "") assert "Assets" in resp.get(
"error", "") or "under project root" in resp.get("error", "")

View File

@ -15,9 +15,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -26,14 +28,18 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def load_module(path, name): def load_module(path, name):
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
return module return module
manage_script_module = load_module(SRC / "tools" / "manage_script.py", "manage_script_module")
manage_asset_module = load_module(SRC / "tools" / "manage_asset.py", "manage_asset_module") manage_script_module = load_module(
SRC / "tools" / "manage_script.py", "manage_script_module")
manage_asset_module = load_module(
SRC / "tools" / "manage_asset.py", "manage_asset_module")
class DummyMCP: class DummyMCP:
@ -46,16 +52,19 @@ class DummyMCP:
return func return func
return decorator return decorator
def setup_manage_script(): def setup_manage_script():
mcp = DummyMCP() mcp = DummyMCP()
manage_script_module.register_manage_script_tools(mcp) manage_script_module.register_manage_script_tools(mcp)
return mcp.tools return mcp.tools
def setup_manage_asset(): def setup_manage_asset():
mcp = DummyMCP() mcp = DummyMCP()
manage_asset_module.register_manage_asset_tools(mcp) manage_asset_module.register_manage_asset_tools(mcp)
return mcp.tools return mcp.tools
def test_apply_text_edits_long_file(monkeypatch): def test_apply_text_edits_long_file(monkeypatch):
tools = setup_manage_script() tools = setup_manage_script()
apply_edits = tools["apply_text_edits"] apply_edits = tools["apply_text_edits"]
@ -66,15 +75,18 @@ def test_apply_text_edits_long_file(monkeypatch):
captured["params"] = params captured["params"] = params
return {"success": True} return {"success": True}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script_module,
"send_command_with_retry", fake_send)
edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} edit = {"startLine": 1005, "startCol": 0,
"endLine": 1005, "endCol": 5, "newText": "Hello"}
resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit])
assert captured["cmd"] == "manage_script" assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "apply_text_edits" assert captured["params"]["action"] == "apply_text_edits"
assert captured["params"]["edits"][0]["startLine"] == 1005 assert captured["params"]["edits"][0]["startLine"] == 1005
assert resp["success"] is True assert resp["success"] is True
def test_sequential_edits_use_precondition(monkeypatch): def test_sequential_edits_use_precondition(monkeypatch):
tools = setup_manage_script() tools = setup_manage_script()
apply_edits = tools["apply_text_edits"] apply_edits = tools["apply_text_edits"]
@ -84,12 +96,16 @@ def test_sequential_edits_use_precondition(monkeypatch):
calls.append(params) calls.append(params)
return {"success": True, "sha256": f"hash{len(calls)}"} return {"success": True, "sha256": f"hash{len(calls)}"}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script_module,
"send_command_with_retry", fake_send)
edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} edit1 = {"startLine": 1, "startCol": 0, "endLine": 1,
"endCol": 0, "newText": "//header\n"}
resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1])
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} edit2 = {"startLine": 2, "startCol": 0, "endLine": 2,
resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"]) "endCol": 0, "newText": "//second\n"}
resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs",
[edit2], precondition_sha256=resp1["sha256"])
assert calls[1]["precondition_sha256"] == resp1["sha256"] assert calls[1]["precondition_sha256"] == resp1["sha256"]
assert resp2["sha256"] == "hash2" assert resp2["sha256"] == "hash2"
@ -104,10 +120,12 @@ def test_apply_text_edits_forwards_options(monkeypatch):
captured["params"] = params captured["params"] = params
return {"success": True} return {"success": True}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script_module,
"send_command_with_retry", fake_send)
opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"}
apply_edits(None, "unity://path/Assets/Scripts/File.cs", [{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":"x"}], options=opts) apply_edits(None, "unity://path/Assets/Scripts/File.cs",
[{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts)
assert captured["params"].get("options") == opts assert captured["params"].get("options") == opts
@ -120,16 +138,20 @@ def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
captured["params"] = params captured["params"] = params
return {"success": True} return {"success": True}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) monkeypatch.setattr(manage_script_module,
"send_command_with_retry", fake_send)
edits = [ edits = [
{"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"}, {"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"},
{"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"}, {"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"},
] ]
apply_edits(None, "unity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x") apply_edits(None, "unity://path/Assets/Scripts/File.cs",
edits, precondition_sha256="x")
opts = captured["params"].get("options", {}) opts = captured["params"].get("options", {})
assert opts.get("applyMode") == "atomic" assert opts.get("applyMode") == "atomic"
def test_manage_asset_prefab_modify_request(monkeypatch): def test_manage_asset_prefab_modify_request(monkeypatch):
tools = setup_manage_asset() tools = setup_manage_asset()
manage_asset = tools["manage_asset"] manage_asset = tools["manage_asset"]
@ -140,8 +162,10 @@ def test_manage_asset_prefab_modify_request(monkeypatch):
captured["params"] = params captured["params"] = params
return {"success": True} return {"success": True}
monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async) monkeypatch.setattr(manage_asset_module,
monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object()) "async_send_command_with_retry", fake_async)
monkeypatch.setattr(manage_asset_module,
"get_unity_connection", lambda: object())
async def run(): async def run():
resp = await manage_asset( resp = await manage_asset(

View File

@ -1,18 +1,21 @@
import os import os
import importlib import importlib
def test_endpoint_rejects_non_http(tmp_path, monkeypatch): def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
# Point data dir to temp to avoid touching real files # Point data dir to temp to avoid touching real files
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd") monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd")
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry") telemetry = importlib.import_module(
"UnityMcpBridge.UnityMcpServer~.src.telemetry")
importlib.reload(telemetry) importlib.reload(telemetry)
tc = telemetry.TelemetryCollector() tc = telemetry.TelemetryCollector()
# Should have fallen back to default endpoint # Should have fallen back to default endpoint
assert tc.config.endpoint == tc.config.default_endpoint assert tc.config.endpoint == tc.config.default_endpoint
def test_config_preferred_then_env_override(tmp_path, monkeypatch): def test_config_preferred_then_env_override(tmp_path, monkeypatch):
# Simulate config telemetry endpoint # Simulate config telemetry endpoint
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
@ -20,27 +23,32 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch):
# Patch config.telemetry_endpoint via import mocking # Patch config.telemetry_endpoint via import mocking
import importlib import importlib
cfg_mod = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.config") cfg_mod = importlib.import_module(
"UnityMcpBridge.UnityMcpServer~.src.config")
old_endpoint = cfg_mod.config.telemetry_endpoint old_endpoint = cfg_mod.config.telemetry_endpoint
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry" cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
try: try:
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry") telemetry = importlib.import_module(
"UnityMcpBridge.UnityMcpServer~.src.telemetry")
importlib.reload(telemetry) importlib.reload(telemetry)
tc = telemetry.TelemetryCollector() tc = telemetry.TelemetryCollector()
assert tc.config.endpoint == "https://example.com/telemetry" assert tc.config.endpoint == "https://example.com/telemetry"
# Env should override config # Env should override config
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "https://override.example/ep") monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT",
"https://override.example/ep")
importlib.reload(telemetry) importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector() tc2 = telemetry.TelemetryCollector()
assert tc2.config.endpoint == "https://override.example/ep" assert tc2.config.endpoint == "https://override.example/ep"
finally: finally:
cfg_mod.config.telemetry_endpoint = old_endpoint cfg_mod.config.telemetry_endpoint = old_endpoint
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch): def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry") telemetry = importlib.import_module(
"UnityMcpBridge.UnityMcpServer~.src.telemetry")
importlib.reload(telemetry) importlib.reload(telemetry)
tc1 = telemetry.TelemetryCollector() tc1 = telemetry.TelemetryCollector()
@ -53,4 +61,3 @@ def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
importlib.reload(telemetry) importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector() tc2 = telemetry.TelemetryCollector()
assert tc2._customer_uuid == first_uuid assert tc2._customer_uuid == first_uuid

View File

@ -16,9 +16,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -72,12 +74,12 @@ def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
time.sleep(0.3) time.sleep(0.3)
# Verify drops were logged (queue full backpressure) # Verify drops were logged (queue full backpressure)
dropped_logs = [m for m in caplog.messages if "Telemetry queue full; dropping" in m] dropped_logs = [
m for m in caplog.messages if "Telemetry queue full; dropping" in m]
assert len(dropped_logs) >= 1 assert len(dropped_logs) >= 1
# Ensure only one worker thread exists and is alive # Ensure only one worker thread exists and is alive
assert collector._worker.is_alive() assert collector._worker.is_alive()
worker_threads = [t for t in threading.enumerate() if t is collector._worker] worker_threads = [
t for t in threading.enumerate() if t is collector._worker]
assert len(worker_threads) == 1 assert len(worker_threads) == 1

View File

@ -3,7 +3,8 @@ import importlib
def _get_decorator_module(): def _get_decorator_module():
# Import the telemetry_decorator module from the Unity MCP server src # Import the telemetry_decorator module from the Unity MCP server src
mod = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry_decorator") mod = importlib.import_module(
"UnityMcpBridge.UnityMcpServer~.src.telemetry_decorator")
return mod return mod
@ -79,5 +80,3 @@ def test_subaction_none_when_not_present(monkeypatch):
_ = wrapped(None, name="X") _ = wrapped(None, name="X")
assert captured["tool_name"] == "apply_text_edits" assert captured["tool_name"] == "apply_text_edits"
assert captured["sub_action"] is None assert captured["sub_action"] is None

View File

@ -1,3 +1,4 @@
from unity_connection import UnityConnection
import sys import sys
import json import json
import struct import struct
@ -24,8 +25,6 @@ if SRC is None:
) )
sys.path.insert(0, str(SRC)) sys.path.insert(0, str(SRC))
from unity_connection import UnityConnection
def start_dummy_server(greeting: bytes, respond_ping: bool = False): def start_dummy_server(greeting: bytes, respond_ping: bool = False):
"""Start a minimal TCP server for handshake tests.""" """Start a minimal TCP server for handshake tests."""
@ -159,7 +158,10 @@ def test_unframed_data_disconnect():
def test_zero_length_payload_heartbeat(): def test_zero_length_payload_heartbeat():
# Server that sends handshake and a zero-length heartbeat frame followed by a pong payload # Server that sends handshake and a zero-length heartbeat frame followed by a pong payload
import socket, struct, threading, time import socket
import struct
import threading
import time
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0)) sock.bind(("127.0.0.1", 0))
@ -181,8 +183,10 @@ def test_zero_length_payload_heartbeat():
conn.sendall(struct.pack(">Q", len(payload)) + payload) conn.sendall(struct.pack(">Q", len(payload)) + payload)
time.sleep(0.02) time.sleep(0.02)
finally: finally:
try: conn.close() try:
except Exception: pass conn.close()
except Exception:
pass
sock.close() sock.close()
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()

View File

@ -12,9 +12,11 @@ mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server") server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: class _Dummy:
pass pass
fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg server_pkg.fastmcp = fastmcp_pkg
@ -23,13 +25,17 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load_module(path: pathlib.Path, name: str): def _load_module(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
return mod return mod
manage_script = _load_module(SRC / "tools" / "manage_script.py", "manage_script_mod")
manage_script = _load_module(
SRC / "tools" / "manage_script.py", "manage_script_mod")
class DummyMCP: class DummyMCP:
def __init__(self): def __init__(self):
@ -41,11 +47,13 @@ class DummyMCP:
return fn return fn
return deco return deco
def setup_tools(): def setup_tools():
mcp = DummyMCP() mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp) manage_script.register_manage_script_tools(mcp)
return mcp.tools return mcp.tools
def test_validate_script_returns_counts(monkeypatch): def test_validate_script_returns_counts(monkeypatch):
tools = setup_tools() tools = setup_tools()
validate_script = tools["validate_script"] validate_script = tools["validate_script"]

View File

@ -21,7 +21,8 @@ def dlog(*args):
def find_status_files() -> list[Path]: def find_status_files() -> list[Path]:
home = Path.home() home = Path.home()
status_dir = Path(os.environ.get("UNITY_MCP_STATUS_DIR", home / ".unity-mcp")) status_dir = Path(os.environ.get(
"UNITY_MCP_STATUS_DIR", home / ".unity-mcp"))
if not status_dir.exists(): if not status_dir.exists():
return [] return []
return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True) return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
@ -87,7 +88,8 @@ def make_ping_frame() -> bytes:
def make_execute_menu_item(menu_path: str) -> bytes: def make_execute_menu_item(menu_path: str) -> bytes:
# Retained for manual debugging; not used in normal stress runs # Retained for manual debugging; not used in normal stress runs
payload = {"type": "execute_menu_item", "params": {"action": "execute", "menu_path": menu_path}} payload = {"type": "execute_menu_item", "params": {
"action": "execute", "menu_path": menu_path}}
return json.dumps(payload).encode("utf-8") return json.dumps(payload).encode("utf-8")
@ -102,7 +104,8 @@ async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: d
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT) await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
# Send a quick ping first # Send a quick ping first
await write_frame(writer, make_ping_frame()) await write_frame(writer, make_ping_frame())
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) # ignore content # ignore content
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
# Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task. # Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task.
while time.time() < stop_time: while time.time() < stop_time:
@ -182,7 +185,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
if relative: if relative:
# Derive name and directory for ManageScript and compute precondition SHA + EOF position # Derive name and directory for ManageScript and compute precondition SHA + EOF position
name_base = Path(relative).stem name_base = Path(relative).stem
dir_path = str(Path(relative).parent).replace('\\', '/') dir_path = str(
Path(relative).parent).replace('\\', '/')
# 1) Read current contents via manage_script.read to compute SHA and true EOF location # 1) Read current contents via manage_script.read to compute SHA and true EOF location
contents = None contents = None
@ -203,8 +207,10 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
await write_frame(writer, json.dumps(read_payload).encode("utf-8")) await write_frame(writer, json.dumps(read_payload).encode("utf-8"))
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
read_obj = json.loads(resp.decode("utf-8", errors="ignore")) read_obj = json.loads(
result = read_obj.get("result", read_obj) if isinstance(read_obj, dict) else {} resp.decode("utf-8", errors="ignore"))
result = read_obj.get("result", read_obj) if isinstance(
read_obj, dict) else {}
if result.get("success"): if result.get("success"):
data_obj = result.get("data", {}) data_obj = result.get("data", {})
contents = data_obj.get("contents") or "" contents = data_obj.get("contents") or ""
@ -222,13 +228,15 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
pass pass
if not read_success or contents is None: if not read_success or contents is None:
stats["apply_errors"] = stats.get("apply_errors", 0) + 1 stats["apply_errors"] = stats.get(
"apply_errors", 0) + 1
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
continue continue
# Compute SHA and EOF insertion point # Compute SHA and EOF insertion point
import hashlib import hashlib
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() sha = hashlib.sha256(
contents.encode("utf-8")).hexdigest()
lines = contents.splitlines(keepends=True) lines = contents.splitlines(keepends=True)
# Insert at true EOF (safe against header guards) # Insert at true EOF (safe against header guards)
end_line = len(lines) + 1 # 1-based exclusive end end_line = len(lines) + 1 # 1-based exclusive end
@ -237,7 +245,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
# Build a unique marker append; ensure it begins with a newline if needed # Build a unique marker append; ensure it begins with a newline if needed
marker = f"// MCP_STRESS seq={seq} time={int(time.time())}" marker = f"// MCP_STRESS seq={seq} time={int(time.time())}"
seq += 1 seq += 1
insert_text = ("\n" if not contents.endswith("\n") else "") + marker + "\n" insert_text = ("\n" if not contents.endswith(
"\n") else "") + marker + "\n"
# 2) Apply text edits with immediate refresh and precondition # 2) Apply text edits with immediate refresh and precondition
apply_payload = { apply_payload = {
@ -269,11 +278,14 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
await write_frame(writer, json.dumps(apply_payload).encode("utf-8")) await write_frame(writer, json.dumps(apply_payload).encode("utf-8"))
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
try: try:
data = json.loads(resp.decode("utf-8", errors="ignore")) data = json.loads(resp.decode(
result = data.get("result", data) if isinstance(data, dict) else {} "utf-8", errors="ignore"))
result = data.get("result", data) if isinstance(
data, dict) else {}
ok = bool(result.get("success", False)) ok = bool(result.get("success", False))
if ok: if ok:
stats["applies"] = stats.get("applies", 0) + 1 stats["applies"] = stats.get(
"applies", 0) + 1
apply_success = True apply_success = True
break break
except Exception: except Exception:
@ -290,7 +302,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
except Exception: except Exception:
pass pass
if not apply_success: if not apply_success:
stats["apply_errors"] = stats.get("apply_errors", 0) + 1 stats["apply_errors"] = stats.get(
"apply_errors", 0) + 1
except Exception: except Exception:
pass pass
@ -298,13 +311,17 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
async def main(): async def main():
ap = argparse.ArgumentParser(description="Stress test the Unity MCP bridge with concurrent clients and reload churn") ap = argparse.ArgumentParser(
description="Stress test the Unity MCP bridge with concurrent clients and reload churn")
ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--project", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests")) ap.add_argument("--project", default=str(
ap.add_argument("--unity-file", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs")) Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests"))
ap.add_argument("--unity-file", default=str(Path(__file__).resolve(
).parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs"))
ap.add_argument("--clients", type=int, default=10) ap.add_argument("--clients", type=int, default=10)
ap.add_argument("--duration", type=int, default=60) ap.add_argument("--duration", type=int, default=60)
ap.add_argument("--storm-count", type=int, default=1, help="Number of scripts to touch each cycle") ap.add_argument("--storm-count", type=int, default=1,
help="Number of scripts to touch each cycle")
args = ap.parse_args() args = ap.parse_args()
port = discover_port(args.project) port = discover_port(args.project)
@ -315,10 +332,12 @@ async def main():
# Spawn clients # Spawn clients
for i in range(max(1, args.clients)): for i in range(max(1, args.clients)):
tasks.append(asyncio.create_task(client_loop(i, args.host, port, stop_time, stats))) tasks.append(asyncio.create_task(
client_loop(i, args.host, port, stop_time, stats)))
# Spawn reload churn task # Spawn reload churn task
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port, stats, storm_count=args.storm_count))) tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time,
args.unity_file, args.host, port, stats, storm_count=args.storm_count)))
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
print(json.dumps({"port": port, "stats": stats}, indent=2)) print(json.dumps({"port": port, "stats": stats}, indent=2))
@ -329,5 +348,3 @@ if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass