Autoformat (#297)
parent
47ec46ce93
commit
f6796e61f7
|
|
@ -29,6 +29,7 @@ PATTERNS = [
|
|||
r"validation error .* ctx",
|
||||
]
|
||||
|
||||
|
||||
def should_skip(msg: str) -> bool:
|
||||
if not msg:
|
||||
return False
|
||||
|
|
@ -38,6 +39,7 @@ def should_skip(msg: str) -> bool:
|
|||
return True
|
||||
return False
|
||||
|
||||
|
||||
def summarize_counts(ts: ET.Element):
|
||||
tests = 0
|
||||
failures = 0
|
||||
|
|
@ -53,6 +55,7 @@ def summarize_counts(ts: ET.Element):
|
|||
skipped += 1
|
||||
return tests, failures, errors, skipped
|
||||
|
||||
|
||||
def main(path: str) -> int:
|
||||
if not os.path.exists(path):
|
||||
print(f"[mark_skipped] No JUnit at {path}; nothing to do.")
|
||||
|
|
@ -79,7 +82,8 @@ def main(path: str) -> int:
|
|||
for n in nodes:
|
||||
msg = (n.get("message") or "") + "\n" + (n.text or "")
|
||||
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
|
||||
if to_skip:
|
||||
for n in nodes:
|
||||
|
|
@ -98,12 +102,14 @@ def main(path: str) -> int:
|
|||
|
||||
if changed:
|
||||
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:
|
||||
print(f"[mark_skipped] No environmental failures detected in {path}.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = (
|
||||
sys.argv[1]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,4 +17,3 @@ jobs:
|
|||
uses: jgehrcke/github-repo-stats@RELEASE
|
||||
with:
|
||||
ghtoken: ${{ secrets.ghrs_github_api_token }}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: Unity Tests
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- TestProjects/UnityMCPTests/**
|
||||
- UnityMcpBridge/Editor/**
|
||||
|
|
|
|||
|
|
@ -3,13 +3,8 @@ using System.Collections;
|
|||
|
||||
public class Hello : MonoBehaviour
|
||||
{
|
||||
|
||||
// Use this for initialization
|
||||
void Start()
|
||||
{
|
||||
Debug.Log("Hello World");
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2035,5 +2035,3 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
#endregion
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,3 @@ namespace MCPForUnity.Editor.Data
|
|||
public new float retryDelay = 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,19 +121,19 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
#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)
|
||||
{
|
||||
|
|
@ -148,11 +148,11 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
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 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;
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
public override string ToInlineToml() =>
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -232,10 +232,10 @@ namespace MCPForUnity.External.Tommy
|
|||
public override string ToInlineToml() =>
|
||||
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.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE,
|
||||
var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant()
|
||||
var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ namespace MCPForUnity.External.Tommy
|
|||
{
|
||||
DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat),
|
||||
DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]),
|
||||
var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision])
|
||||
var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision])
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -517,7 +517,7 @@ namespace MCPForUnity.External.Tommy
|
|||
if (collapsedItems.Count == 0)
|
||||
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);
|
||||
|
||||
|
|
@ -539,7 +539,7 @@ namespace MCPForUnity.External.Tommy
|
|||
foreach (var collapsedItem in collapsedItems)
|
||||
{
|
||||
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();
|
||||
first = false;
|
||||
|
|
@ -660,7 +660,7 @@ namespace MCPForUnity.External.Tommy
|
|||
int currentChar;
|
||||
while ((currentChar = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) currentChar;
|
||||
var c = (char)currentChar;
|
||||
|
||||
if (currentState == ParseState.None)
|
||||
{
|
||||
|
|
@ -771,7 +771,7 @@ namespace MCPForUnity.External.Tommy
|
|||
// Consume the ending bracket so we can peek the next character
|
||||
ConsumeChar();
|
||||
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.");
|
||||
keyParts.Clear();
|
||||
|
|
@ -837,7 +837,7 @@ namespace MCPForUnity.External.Tommy
|
|||
AddError($"Unexpected character \"{c}\" at the end of the line.");
|
||||
}
|
||||
|
||||
consume_character:
|
||||
consume_character:
|
||||
reader.Read();
|
||||
col++;
|
||||
}
|
||||
|
|
@ -892,7 +892,7 @@ namespace MCPForUnity.External.Tommy
|
|||
int cur;
|
||||
while ((cur = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
|
||||
if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c))
|
||||
{
|
||||
|
|
@ -941,7 +941,7 @@ namespace MCPForUnity.External.Tommy
|
|||
int cur;
|
||||
while ((cur = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
|
||||
if (TomlSyntax.IsWhiteSpace(c))
|
||||
{
|
||||
|
|
@ -994,8 +994,8 @@ namespace MCPForUnity.External.Tommy
|
|||
return c switch
|
||||
{
|
||||
TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(),
|
||||
TomlSyntax.ARRAY_START_SYMBOL => ReadArray(),
|
||||
var _ => ReadTomlValue()
|
||||
TomlSyntax.ARRAY_START_SYMBOL => ReadArray(),
|
||||
var _ => ReadTomlValue()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1023,7 +1023,7 @@ namespace MCPForUnity.External.Tommy
|
|||
int cur;
|
||||
while ((cur = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
|
||||
// Reached the final character
|
||||
if (c == until) break;
|
||||
|
|
@ -1062,7 +1062,7 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
// Consume the quote character and read the key name
|
||||
col++;
|
||||
buffer.Append(ReadQuotedValueSingleLine((char) reader.Read()));
|
||||
buffer.Append(ReadQuotedValueSingleLine((char)reader.Read()));
|
||||
quoted = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1076,7 +1076,7 @@ namespace MCPForUnity.External.Tommy
|
|||
// If we see an invalid symbol, let the next parser handle it
|
||||
break;
|
||||
|
||||
consume_character:
|
||||
consume_character:
|
||||
reader.Read();
|
||||
col++;
|
||||
}
|
||||
|
|
@ -1107,7 +1107,7 @@ namespace MCPForUnity.External.Tommy
|
|||
int cur;
|
||||
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;
|
||||
result.Append(c);
|
||||
ConsumeChar();
|
||||
|
|
@ -1134,9 +1134,9 @@ namespace MCPForUnity.External.Tommy
|
|||
TomlNode node = value switch
|
||||
{
|
||||
var v when TomlSyntax.IsBoolean(v) => bool.Parse(v),
|
||||
var v when TomlSyntax.IsNaN(v) => double.NaN,
|
||||
var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity,
|
||||
var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity,
|
||||
var v when TomlSyntax.IsNaN(v) => double.NaN,
|
||||
var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity,
|
||||
var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity,
|
||||
var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR),
|
||||
CultureInfo.InvariantCulture),
|
||||
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
|
||||
{
|
||||
Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase),
|
||||
IntegerBase = (TomlInteger.Base) numberBase
|
||||
IntegerBase = (TomlInteger.Base)numberBase
|
||||
},
|
||||
var _ => null
|
||||
};
|
||||
|
|
@ -1223,7 +1223,7 @@ namespace MCPForUnity.External.Tommy
|
|||
int cur;
|
||||
while ((cur = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
|
||||
if (c == TomlSyntax.ARRAY_END_SYMBOL)
|
||||
{
|
||||
|
|
@ -1274,7 +1274,7 @@ namespace MCPForUnity.External.Tommy
|
|||
expectValue = false;
|
||||
|
||||
continue;
|
||||
consume_character:
|
||||
consume_character:
|
||||
ConsumeChar();
|
||||
}
|
||||
|
||||
|
|
@ -1293,14 +1293,14 @@ namespace MCPForUnity.External.Tommy
|
|||
private TomlNode ReadInlineTable()
|
||||
{
|
||||
ConsumeChar();
|
||||
var result = new TomlTable {IsInline = true};
|
||||
var result = new TomlTable { IsInline = true };
|
||||
TomlNode currentValue = null;
|
||||
var separator = false;
|
||||
var keyParts = new List<string>();
|
||||
int cur;
|
||||
while ((cur = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
|
||||
if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL)
|
||||
{
|
||||
|
|
@ -1343,7 +1343,7 @@ namespace MCPForUnity.External.Tommy
|
|||
currentValue = ReadKeyValuePair(keyParts);
|
||||
continue;
|
||||
|
||||
consume_character:
|
||||
consume_character:
|
||||
ConsumeChar();
|
||||
}
|
||||
|
||||
|
|
@ -1394,15 +1394,15 @@ namespace MCPForUnity.External.Tommy
|
|||
return AddError("Unexpected end of file!");
|
||||
}
|
||||
|
||||
if ((char) cur != quote)
|
||||
if ((char)cur != quote)
|
||||
{
|
||||
excess = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Consume the second quote
|
||||
excess = (char) ConsumeChar();
|
||||
if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false;
|
||||
excess = (char)ConsumeChar();
|
||||
if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false;
|
||||
|
||||
// Consume the final quote
|
||||
ConsumeChar();
|
||||
|
|
@ -1420,7 +1420,7 @@ namespace MCPForUnity.External.Tommy
|
|||
ref bool escaped)
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
|
@ -1487,7 +1487,7 @@ namespace MCPForUnity.External.Tommy
|
|||
{
|
||||
// Consume the character
|
||||
col++;
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped);
|
||||
if (readDone)
|
||||
{
|
||||
|
|
@ -1529,10 +1529,10 @@ namespace MCPForUnity.External.Tommy
|
|||
int cur;
|
||||
while ((cur = ConsumeChar()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
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;
|
||||
}
|
||||
// Trim the first newline
|
||||
|
|
@ -1582,7 +1582,7 @@ namespace MCPForUnity.External.Tommy
|
|||
if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL)
|
||||
{
|
||||
var next = reader.Peek();
|
||||
var nc = (char) next;
|
||||
var nc = (char)next;
|
||||
if (next >= 0)
|
||||
{
|
||||
// ...and the next char is empty space, we must skip all whitespaces
|
||||
|
|
@ -1614,7 +1614,7 @@ namespace MCPForUnity.External.Tommy
|
|||
quotesEncountered = 0;
|
||||
while ((cur = reader.Peek()) >= 0)
|
||||
{
|
||||
var c = (char) cur;
|
||||
var c = (char)cur;
|
||||
if (c == quote && ++quotesEncountered < 3)
|
||||
{
|
||||
sb.Append(c);
|
||||
|
|
@ -1677,7 +1677,7 @@ namespace MCPForUnity.External.Tommy
|
|||
{
|
||||
if (node.IsArray && arrayTable)
|
||||
{
|
||||
var arr = (TomlArray) node;
|
||||
var arr = (TomlArray)node;
|
||||
|
||||
if (!arr.IsTableArray)
|
||||
{
|
||||
|
|
@ -1751,7 +1751,7 @@ namespace MCPForUnity.External.Tommy
|
|||
latestNode = node;
|
||||
}
|
||||
|
||||
var result = (TomlTable) latestNode;
|
||||
var result = (TomlTable)latestNode;
|
||||
result.isImplicit = false;
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1779,7 +1779,7 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1960,7 +1960,7 @@ namespace MCPForUnity.External.Tommy
|
|||
public const char LITERAL_STRING_SYMBOL = '\'';
|
||||
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;
|
||||
|
||||
|
|
@ -2057,17 +2057,17 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i)
|
||||
? $"\\U{char.ConvertToUtf32(txt, i++):X8}"
|
||||
: $"\\u{(ushort) c:X4}";
|
||||
: $"\\u{(ushort)c:X4}";
|
||||
|
||||
stringBuilder.Append(c switch
|
||||
{
|
||||
'\b' => @"\b",
|
||||
'\t' => @"\t",
|
||||
'\b' => @"\b",
|
||||
'\t' => @"\t",
|
||||
'\n' when escapeNewlines => @"\n",
|
||||
'\f' => @"\f",
|
||||
'\f' => @"\f",
|
||||
'\r' when escapeNewlines => @"\r",
|
||||
'\\' => @"\\",
|
||||
'\"' => @"\""",
|
||||
'\\' => @"\\",
|
||||
'\"' => @"\""",
|
||||
var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue =>
|
||||
CodePoint(txt, ref i, c),
|
||||
var _ => c
|
||||
|
|
@ -2115,16 +2115,16 @@ namespace MCPForUnity.External.Tommy
|
|||
|
||||
stringBuilder.Append(c switch
|
||||
{
|
||||
'b' => "\b",
|
||||
't' => "\t",
|
||||
'n' => "\n",
|
||||
'f' => "\f",
|
||||
'r' => "\r",
|
||||
'\'' => "\'",
|
||||
'\"' => "\"",
|
||||
'\\' => "\\",
|
||||
'u' => CodePoint(next, txt, ref num, 4),
|
||||
'U' => CodePoint(next, txt, ref num, 8),
|
||||
'b' => "\b",
|
||||
't' => "\t",
|
||||
'n' => "\n",
|
||||
'f' => "\f",
|
||||
'r' => "\r",
|
||||
'\'' => "\'",
|
||||
'\"' => "\"",
|
||||
'\\' => "\\",
|
||||
'u' => CodePoint(next, txt, ref num, 4),
|
||||
'U' => CodePoint(next, txt, ref num, 8),
|
||||
var _ => throw new Exception("Undefined escape sequence!")
|
||||
});
|
||||
i = num + 2;
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
var so = new StringBuilder();
|
||||
var se = new StringBuilder();
|
||||
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;
|
||||
|
||||
|
|
@ -276,5 +276,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -244,8 +244,9 @@ namespace MCPForUnity.Editor.Helpers
|
|||
// Basic filtering (readable, not indexer, not transform which is handled elsewhere)
|
||||
if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue;
|
||||
// Add if not already added (handles overrides - keep the most derived version)
|
||||
if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) {
|
||||
propertiesToCache.Add(propInfo);
|
||||
if (!propertiesToCache.Any(p => p.Name == propInfo.Name))
|
||||
{
|
||||
propertiesToCache.Add(propInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,8 +259,8 @@ namespace MCPForUnity.Editor.Helpers
|
|||
{
|
||||
if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields
|
||||
|
||||
// Add if not already added (handles hiding - keep the most derived version)
|
||||
if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
|
||||
// Add if not already added (handles hiding - keep the most derived version)
|
||||
if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
|
||||
|
||||
bool shouldInclude = false;
|
||||
if (includeNonPublicSerializedFields)
|
||||
|
|
@ -310,10 +311,10 @@ namespace MCPForUnity.Editor.Helpers
|
|||
propName == "particleSystem" ||
|
||||
// Also skip potentially problematic Matrix properties prone to cycles/errors
|
||||
propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")
|
||||
{
|
||||
// Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
|
||||
skipProperty = true;
|
||||
}
|
||||
{
|
||||
// Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
|
||||
skipProperty = true;
|
||||
}
|
||||
// --- End Skip Generic Properties ---
|
||||
|
||||
// --- Skip specific potentially problematic Camera properties ---
|
||||
|
|
@ -345,11 +346,11 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
// --- End Skip Transform Properties ---
|
||||
|
||||
// Skip if flagged
|
||||
if (skipProperty)
|
||||
{
|
||||
// Skip if flagged
|
||||
if (skipProperty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -362,7 +363,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
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
|
||||
foreach (var fieldInfo in cachedData.SerializableFields)
|
||||
{
|
||||
try
|
||||
try
|
||||
{
|
||||
// --- Add detailed logging for fields ---
|
||||
// Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}");
|
||||
|
|
@ -385,7 +386,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
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 ---
|
||||
|
|
@ -458,19 +459,19 @@ namespace MCPForUnity.Editor.Helpers
|
|||
case JTokenType.Boolean:
|
||||
return token.ToObject<bool>();
|
||||
case JTokenType.Date:
|
||||
return token.ToObject<DateTime>();
|
||||
case JTokenType.Guid:
|
||||
return token.ToObject<Guid>();
|
||||
case JTokenType.Uri:
|
||||
return token.ToObject<Uri>();
|
||||
case JTokenType.TimeSpan:
|
||||
return token.ToObject<TimeSpan>();
|
||||
return token.ToObject<DateTime>();
|
||||
case JTokenType.Guid:
|
||||
return token.ToObject<Guid>();
|
||||
case JTokenType.Uri:
|
||||
return token.ToObject<Uri>();
|
||||
case JTokenType.TimeSpan:
|
||||
return token.ToObject<TimeSpan>();
|
||||
case JTokenType.Bytes:
|
||||
return token.ToObject<byte[]>();
|
||||
return token.ToObject<byte[]>();
|
||||
case JTokenType.Null:
|
||||
return null;
|
||||
case JTokenType.Undefined:
|
||||
return null; // Treat undefined as null
|
||||
case JTokenType.Undefined:
|
||||
return null; // Treat undefined as null
|
||||
|
||||
default:
|
||||
// Fallback for simple value types not explicitly listed
|
||||
|
|
|
|||
|
|
@ -184,4 +184,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,5 +29,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -105,5 +105,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -60,4 +60,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -419,7 +419,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { }
|
||||
string destSubDir = Path.Combine(destinationDir, dirName);
|
||||
CopyDirectoryRecursive(dirPath, destSubDir);
|
||||
NextDir: ;
|
||||
NextDir:;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,7 +467,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
string uvPath = FindUvPath();
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -486,7 +486,7 @@ namespace MCPForUnity.Editor.Helpers
|
|||
var sbOut = new StringBuilder();
|
||||
var sbErr = new StringBuilder();
|
||||
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())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -147,5 +147,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,4 +22,3 @@ namespace MCPForUnity.Editor.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,8 @@ namespace MCPForUnity.Editor
|
|||
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();
|
||||
}
|
||||
catch { }
|
||||
|
|
@ -516,159 +517,159 @@ namespace MCPForUnity.Editor
|
|||
lock (clientsLock) { activeClients.Add(client); }
|
||||
try
|
||||
{
|
||||
// Framed I/O only; legacy mode removed
|
||||
try
|
||||
{
|
||||
if (IsDebugEnabled())
|
||||
// Framed I/O only; legacy mode removed
|
||||
try
|
||||
{
|
||||
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
// Strict framing: always require FRAMING=1 and frame all I/O
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
}
|
||||
catch { }
|
||||
try
|
||||
{
|
||||
string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n";
|
||||
byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake);
|
||||
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
|
||||
catch { }
|
||||
// Strict framing: always require FRAMING=1 and frame all I/O
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
}
|
||||
catch { }
|
||||
try
|
||||
{
|
||||
string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n";
|
||||
byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake);
|
||||
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
|
||||
#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
|
||||
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
|
||||
#endif
|
||||
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;
|
||||
}
|
||||
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
|
||||
}
|
||||
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;
|
||||
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)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
|
|
@ -806,116 +807,116 @@ namespace MCPForUnity.Editor
|
|||
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
|
||||
try
|
||||
{
|
||||
// Heartbeat without holding the queue lock
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
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
|
||||
// Heartbeat without holding the queue lock
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
if (now >= nextHeartbeatAt)
|
||||
{
|
||||
// Special case handling
|
||||
if (string.IsNullOrEmpty(commandText))
|
||||
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
|
||||
{
|
||||
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",
|
||||
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
|
||||
error = ex.Message,
|
||||
commandType = "Unknown (error during processing)",
|
||||
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);
|
||||
string responseJson = JsonConvert.SerializeObject(response);
|
||||
tcs.SetResult(responseJson);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
|
||||
|
||||
var response = new
|
||||
{
|
||||
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); }
|
||||
}
|
||||
|
||||
// Remove quickly under lock
|
||||
lock (lockObj) { commandQueue.Remove(id); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,4 +48,3 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -964,8 +964,8 @@ namespace MCPForUnity.Editor.Tools
|
|||
List<Component> componentsToIterate = new List<Component>(originalComponents ?? Array.Empty<Component>()); // Copy immediately, handle null case
|
||||
int componentCount = componentsToIterate.Count;
|
||||
originalComponents = null; // Null the original reference
|
||||
// Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop...");
|
||||
// --- End Copy and Null ---
|
||||
// Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop...");
|
||||
// --- End Copy and Null ---
|
||||
|
||||
var componentData = new List<object>();
|
||||
|
||||
|
|
@ -1181,7 +1181,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
return removeResult; // Return error
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
// Use the new serializer helper
|
||||
// Use the new serializer helper
|
||||
return Response.Success(
|
||||
$"Component '{typeName}' removed from '{targetGo.name}'.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
|
|
@ -1230,7 +1230,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
return setResult; // Return error
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
// Use the new serializer helper
|
||||
// Use the new serializer helper
|
||||
return Response.Success(
|
||||
$"Properties set for component '{compName}' on '{targetGo.name}'.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
|
|
@ -1693,8 +1693,8 @@ namespace MCPForUnity.Editor.Tools
|
|||
BindingFlags flags =
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
||||
|
||||
// Use shared serializer to avoid per-call allocation
|
||||
var inputSerializer = InputSerializer;
|
||||
// Use shared serializer to avoid per-call allocation
|
||||
var inputSerializer = InputSerializer;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -1716,8 +1716,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
propInfo.SetValue(target, convertedValue);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}");
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -1725,16 +1726,17 @@ namespace MCPForUnity.Editor.Tools
|
|||
FieldInfo fieldInfo = type.GetField(memberName, flags);
|
||||
if (fieldInfo != null) // Check if !IsLiteral?
|
||||
{
|
||||
// Use the inputSerializer for conversion
|
||||
// Use the inputSerializer for conversion
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -1881,12 +1883,17 @@ namespace MCPForUnity.Editor.Tools
|
|||
if (value is JArray jArray)
|
||||
{
|
||||
// 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 { 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
|
||||
} else if (jArray.Count == 2) {
|
||||
}
|
||||
else if (jArray.Count == 2)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// Try converting to Texture using the serializer/converter
|
||||
try {
|
||||
try
|
||||
{
|
||||
Texture texture = value.ToObject<Texture>(inputSerializer);
|
||||
if (texture != null) {
|
||||
if (texture != null)
|
||||
{
|
||||
material.SetTexture(finalPart, texture);
|
||||
return true;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
Debug.LogWarning(
|
||||
|
|
@ -1927,7 +1937,8 @@ namespace MCPForUnity.Editor.Tools
|
|||
finalPropInfo.SetValue(currentObject, convertedValue);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
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)
|
||||
{
|
||||
Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}");
|
||||
// Optionally re-throw or return null/default
|
||||
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
throw; // Re-throw to indicate failure higher up
|
||||
Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}");
|
||||
// Optionally re-throw or return null/default
|
||||
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
throw; // Re-throw to indicate failure higher up
|
||||
}
|
||||
catch (ArgumentException argEx)
|
||||
{
|
||||
Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}");
|
||||
throw;
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}");
|
||||
throw;
|
||||
Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}");
|
||||
throw;
|
||||
}
|
||||
// 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.
|
||||
// Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}");
|
||||
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
// 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)}");
|
||||
// return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
{
|
||||
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.");
|
||||
return Vector3.zero;
|
||||
|
|
@ -2068,13 +2080,13 @@ namespace MCPForUnity.Editor.Tools
|
|||
private static Vector2 ParseJTokenToVector2(JToken token)
|
||||
{
|
||||
// ... (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>());
|
||||
}
|
||||
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.");
|
||||
return Vector2.zero;
|
||||
|
|
@ -2088,47 +2100,47 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
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.");
|
||||
return Quaternion.identity;
|
||||
}
|
||||
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"))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
return Color.white;
|
||||
}
|
||||
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"))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
return Rect.zero;
|
||||
}
|
||||
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"))
|
||||
{
|
||||
// Requires Vector3 conversion, which should ideally use the serializer too
|
||||
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 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject<Vector3>(inputSerializer)
|
||||
Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject<Vector3>(inputSerializer)
|
||||
return new Bounds(center, size);
|
||||
}
|
||||
// Array fallback for Bounds is less intuitive, maybe remove?
|
||||
|
|
@ -2141,109 +2153,109 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
// --- End Redundant Parse Helpers ---
|
||||
|
||||
/// <summary>
|
||||
/// Finds a specific UnityEngine.Object based on a find instruction JObject.
|
||||
/// Primarily used by UnityEngineObjectConverter during deserialization.
|
||||
/// </summary>
|
||||
// Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType.
|
||||
public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType)
|
||||
{
|
||||
string findTerm = instruction["find"]?.ToString();
|
||||
string method = instruction["method"]?.ToString()?.ToLower();
|
||||
string componentName = instruction["component"]?.ToString(); // Specific component to get
|
||||
/// <summary>
|
||||
/// Finds a specific UnityEngine.Object based on a find instruction JObject.
|
||||
/// Primarily used by UnityEngineObjectConverter during deserialization.
|
||||
/// </summary>
|
||||
// Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType.
|
||||
public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType)
|
||||
{
|
||||
string findTerm = instruction["find"]?.ToString();
|
||||
string method = instruction["method"]?.ToString()?.ToLower();
|
||||
string componentName = instruction["component"]?.ToString(); // Specific component to get
|
||||
|
||||
if (string.IsNullOrEmpty(findTerm))
|
||||
{
|
||||
Debug.LogWarning("Find instruction missing 'find' term.");
|
||||
return null;
|
||||
}
|
||||
if (string.IsNullOrEmpty(findTerm))
|
||||
{
|
||||
Debug.LogWarning("Find instruction missing 'find' term.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use a flexible default search method if none provided
|
||||
string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method;
|
||||
// Use a flexible default search method if none provided
|
||||
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 (typeof(Material).IsAssignableFrom(targetType) ||
|
||||
typeof(Texture).IsAssignableFrom(targetType) ||
|
||||
typeof(ScriptableObject).IsAssignableFrom(targetType) ||
|
||||
targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc.
|
||||
typeof(AudioClip).IsAssignableFrom(targetType) ||
|
||||
typeof(AnimationClip).IsAssignableFrom(targetType) ||
|
||||
typeof(Font).IsAssignableFrom(targetType) ||
|
||||
typeof(Shader).IsAssignableFrom(targetType) ||
|
||||
typeof(ComputeShader).IsAssignableFrom(targetType) ||
|
||||
typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check
|
||||
{
|
||||
// If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first
|
||||
if (typeof(Material).IsAssignableFrom(targetType) ||
|
||||
typeof(Texture).IsAssignableFrom(targetType) ||
|
||||
typeof(ScriptableObject).IsAssignableFrom(targetType) ||
|
||||
targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc.
|
||||
typeof(AudioClip).IsAssignableFrom(targetType) ||
|
||||
typeof(AnimationClip).IsAssignableFrom(targetType) ||
|
||||
typeof(Font).IsAssignableFrom(targetType) ||
|
||||
typeof(Shader).IsAssignableFrom(targetType) ||
|
||||
typeof(ComputeShader).IsAssignableFrom(targetType) ||
|
||||
typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check
|
||||
{
|
||||
// Try loading directly by path/GUID first
|
||||
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType);
|
||||
if (asset != null) return asset;
|
||||
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed
|
||||
if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset;
|
||||
if (asset != null) return asset;
|
||||
asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed
|
||||
if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset;
|
||||
|
||||
|
||||
// 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[] guids = AssetDatabase.FindAssets(searchFilter);
|
||||
// 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[] guids = AssetDatabase.FindAssets(searchFilter);
|
||||
|
||||
if (guids.Length == 1)
|
||||
{
|
||||
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
|
||||
if (asset != null) return asset;
|
||||
}
|
||||
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.");
|
||||
// Optionally return the first one? Or null? Returning null is safer.
|
||||
return null;
|
||||
}
|
||||
// If still not found, fall through to scene search (though unlikely for assets)
|
||||
}
|
||||
if (guids.Length == 1)
|
||||
{
|
||||
asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType);
|
||||
if (asset != null) return asset;
|
||||
}
|
||||
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.");
|
||||
// Optionally return the first one? Or null? Returning null is safer.
|
||||
return null;
|
||||
}
|
||||
// If still not found, fall through to scene search (though unlikely for assets)
|
||||
}
|
||||
|
||||
|
||||
// --- Scene Object Search ---
|
||||
// Find the GameObject using the internal finder
|
||||
GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse);
|
||||
// --- Scene Object Search ---
|
||||
// Find the GameObject using the internal finder
|
||||
GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse);
|
||||
|
||||
if (foundGo == null)
|
||||
{
|
||||
// Don't warn yet, could still be an asset not found above
|
||||
// Debug.LogWarning($"Could not find GameObject using instruction: {instruction}");
|
||||
return null;
|
||||
}
|
||||
if (foundGo == null)
|
||||
{
|
||||
// Don't warn yet, could still be an asset not found above
|
||||
// Debug.LogWarning($"Could not find GameObject using instruction: {instruction}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Now, get the target object/component from the found GameObject
|
||||
if (targetType == typeof(GameObject))
|
||||
{
|
||||
return foundGo; // We were looking for a GameObject
|
||||
}
|
||||
else if (typeof(Component).IsAssignableFrom(targetType))
|
||||
{
|
||||
Type componentToGetType = targetType;
|
||||
if (!string.IsNullOrEmpty(componentName))
|
||||
{
|
||||
Type specificCompType = FindType(componentName);
|
||||
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
|
||||
{
|
||||
componentToGetType = specificCompType;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'.");
|
||||
}
|
||||
}
|
||||
// Now, get the target object/component from the found GameObject
|
||||
if (targetType == typeof(GameObject))
|
||||
{
|
||||
return foundGo; // We were looking for a GameObject
|
||||
}
|
||||
else if (typeof(Component).IsAssignableFrom(targetType))
|
||||
{
|
||||
Type componentToGetType = targetType;
|
||||
if (!string.IsNullOrEmpty(componentName))
|
||||
{
|
||||
Type specificCompType = FindType(componentName);
|
||||
if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType))
|
||||
{
|
||||
componentToGetType = specificCompType;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
Component foundComp = foundGo.GetComponent(componentToGetType);
|
||||
if (foundComp == null)
|
||||
{
|
||||
Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
|
||||
}
|
||||
return foundComp;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Component foundComp = foundGo.GetComponent(componentToGetType);
|
||||
if (foundComp == null)
|
||||
{
|
||||
Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'.");
|
||||
}
|
||||
return foundComp;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2533,4 +2545,3 @@ namespace MCPForUnity.Editor.Tools
|
|||
// They are now in Helpers.GameObjectSerializer
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -472,4 +472,3 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2658,4 +2658,3 @@ static class ManageScriptRefreshHelpers
|
|||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
|| trimmedLine.StartsWith("UnityEditor.")
|
||||
|| trimmedLine.Contains("(at ")
|
||||
|| // 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
|
||||
&& char.IsUpper(trimmedLine[0])
|
||||
|
|
@ -568,4 +568,3 @@ namespace MCPForUnity.Editor.Tools
|
|||
*/
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ namespace MCPForUnity.Editor.Windows
|
|||
EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle);
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// (Auto-connect toggle removed per design)
|
||||
// (Auto-connect toggle removed per design)
|
||||
|
||||
// Client selector
|
||||
string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray();
|
||||
|
|
@ -582,10 +582,10 @@ namespace MCPForUnity.Editor.Windows
|
|||
MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
lastClientRegisteredOk = anyRegistered
|
||||
|| IsCursorConfigured(pythonDir)
|
||||
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|
||||
|| IsClaudeConfigured();
|
||||
lastClientRegisteredOk = anyRegistered
|
||||
|| IsCursorConfigured(pythonDir)
|
||||
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|
||||
|| IsClaudeConfigured();
|
||||
}
|
||||
|
||||
// Ensure the bridge is listening and has a fresh saved port
|
||||
|
|
@ -676,10 +676,10 @@ namespace MCPForUnity.Editor.Windows
|
|||
UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
lastClientRegisteredOk = anyRegistered
|
||||
|| IsCursorConfigured(pythonDir)
|
||||
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|
||||
|| IsClaudeConfigured();
|
||||
lastClientRegisteredOk = anyRegistered
|
||||
|| IsCursorConfigured(pythonDir)
|
||||
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|
||||
|| IsClaudeConfigured();
|
||||
|
||||
// Restart/ensure bridge
|
||||
MCPForUnityBridge.StartAutoConnect();
|
||||
|
|
@ -695,11 +695,11 @@ namespace MCPForUnity.Editor.Windows
|
|||
}
|
||||
}
|
||||
|
||||
private static bool IsCursorConfigured(string pythonDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
private static bool IsCursorConfigured(string pythonDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".cursor", "mcp.json")
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
|
|
@ -861,30 +861,30 @@ namespace MCPForUnity.Editor.Windows
|
|||
|
||||
private void DrawClientConfigurationCompact(McpClient mcpClient)
|
||||
{
|
||||
// Special pre-check for Claude Code: if CLI missing, reflect in status UI
|
||||
if (mcpClient.mcpType == McpTypes.ClaudeCode)
|
||||
{
|
||||
string claudeCheck = ExecPath.ResolveClaude();
|
||||
if (string.IsNullOrEmpty(claudeCheck))
|
||||
{
|
||||
mcpClient.configStatus = "Claude Not Found";
|
||||
mcpClient.status = McpStatus.NotConfigured;
|
||||
}
|
||||
}
|
||||
// Special pre-check for Claude Code: if CLI missing, reflect in status UI
|
||||
if (mcpClient.mcpType == McpTypes.ClaudeCode)
|
||||
{
|
||||
string claudeCheck = ExecPath.ResolveClaude();
|
||||
if (string.IsNullOrEmpty(claudeCheck))
|
||||
{
|
||||
mcpClient.configStatus = "Claude Not Found";
|
||||
mcpClient.status = McpStatus.NotConfigured;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-check for clients that require uv (all except Claude Code)
|
||||
bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode;
|
||||
bool uvMissingEarly = false;
|
||||
if (uvRequired)
|
||||
{
|
||||
string uvPathEarly = FindUvPath();
|
||||
if (string.IsNullOrEmpty(uvPathEarly))
|
||||
{
|
||||
uvMissingEarly = true;
|
||||
mcpClient.configStatus = "uv Not Found";
|
||||
mcpClient.status = McpStatus.NotConfigured;
|
||||
}
|
||||
}
|
||||
// Pre-check for clients that require uv (all except Claude Code)
|
||||
bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode;
|
||||
bool uvMissingEarly = false;
|
||||
if (uvRequired)
|
||||
{
|
||||
string uvPathEarly = FindUvPath();
|
||||
if (string.IsNullOrEmpty(uvPathEarly))
|
||||
{
|
||||
uvMissingEarly = true;
|
||||
mcpClient.configStatus = "uv Not Found";
|
||||
mcpClient.status = McpStatus.NotConfigured;
|
||||
}
|
||||
}
|
||||
|
||||
// Status display
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
|
@ -899,64 +899,64 @@ namespace MCPForUnity.Editor.Windows
|
|||
};
|
||||
EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
// When Claude CLI is missing, show a clear install hint directly below status
|
||||
if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude()))
|
||||
{
|
||||
GUIStyle installHintStyle = new GUIStyle(clientStatusStyle);
|
||||
installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUIContent installText = new GUIContent("Make sure Claude Code is installed!");
|
||||
Vector2 textSize = installHintStyle.CalcSize(installText);
|
||||
EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false));
|
||||
GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold };
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false)))
|
||||
{
|
||||
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code");
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
// When Claude CLI is missing, show a clear install hint directly below status
|
||||
if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude()))
|
||||
{
|
||||
GUIStyle installHintStyle = new GUIStyle(clientStatusStyle);
|
||||
installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUIContent installText = new GUIContent("Make sure Claude Code is installed!");
|
||||
Vector2 textSize = installHintStyle.CalcSize(installText);
|
||||
EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false));
|
||||
GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold };
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false)))
|
||||
{
|
||||
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code");
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls
|
||||
if (uvRequired && uvMissingEarly)
|
||||
{
|
||||
GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
fontSize = 12,
|
||||
fontStyle = FontStyle.Bold,
|
||||
wordWrap = false
|
||||
};
|
||||
installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUIContent installText2 = new GUIContent("Make sure uv is installed!");
|
||||
Vector2 sz = installHintStyle2.CalcSize(installText2);
|
||||
EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false));
|
||||
GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold };
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false)))
|
||||
{
|
||||
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf");
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
// If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls
|
||||
if (uvRequired && uvMissingEarly)
|
||||
{
|
||||
GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
fontSize = 12,
|
||||
fontStyle = FontStyle.Bold,
|
||||
wordWrap = false
|
||||
};
|
||||
installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUIContent installText2 = new GUIContent("Make sure uv is installed!");
|
||||
Vector2 sz = installHintStyle2.CalcSize(installText2);
|
||||
EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false));
|
||||
GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold };
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false)))
|
||||
{
|
||||
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf");
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22)))
|
||||
{
|
||||
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, "");
|
||||
if (!string.IsNullOrEmpty(picked))
|
||||
{
|
||||
EditorPrefs.SetString("MCPForUnity.UvPath", picked);
|
||||
ConfigureMcpClient(mcpClient);
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
return;
|
||||
}
|
||||
EditorGUILayout.Space(8);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22)))
|
||||
{
|
||||
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, "");
|
||||
if (!string.IsNullOrEmpty(picked))
|
||||
{
|
||||
EditorPrefs.SetString("MCPForUnity.UvPath", picked);
|
||||
ConfigureMcpClient(mcpClient);
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action buttons in horizontal layout
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
|
@ -968,57 +968,57 @@ namespace MCPForUnity.Editor.Windows
|
|||
ConfigureMcpClient(mcpClient);
|
||||
}
|
||||
}
|
||||
else if (mcpClient.mcpType == McpTypes.ClaudeCode)
|
||||
{
|
||||
bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude());
|
||||
if (claudeAvailable)
|
||||
{
|
||||
bool isConfigured = mcpClient.status == McpStatus.Configured;
|
||||
string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code";
|
||||
if (GUILayout.Button(buttonText, GUILayout.Height(32)))
|
||||
{
|
||||
if (isConfigured)
|
||||
{
|
||||
UnregisterWithClaudeCode();
|
||||
}
|
||||
else
|
||||
{
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
RegisterWithClaudeCode(pythonDir);
|
||||
}
|
||||
}
|
||||
// Hide the picker once a valid binary is available
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true };
|
||||
string resolvedClaude = ExecPath.ResolveClaude();
|
||||
EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
}
|
||||
// CLI picker row (only when not found)
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (!claudeAvailable)
|
||||
{
|
||||
// Only show the picker button in not-found state (no redundant "not found" label)
|
||||
if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22)))
|
||||
{
|
||||
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, "");
|
||||
if (!string.IsNullOrEmpty(picked))
|
||||
{
|
||||
ExecPath.SetClaudeCliPath(picked);
|
||||
// Auto-register after setting a valid path
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
RegisterWithClaudeCode(pythonDir);
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
}
|
||||
else if (mcpClient.mcpType == McpTypes.ClaudeCode)
|
||||
{
|
||||
bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude());
|
||||
if (claudeAvailable)
|
||||
{
|
||||
bool isConfigured = mcpClient.status == McpStatus.Configured;
|
||||
string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code";
|
||||
if (GUILayout.Button(buttonText, GUILayout.Height(32)))
|
||||
{
|
||||
if (isConfigured)
|
||||
{
|
||||
UnregisterWithClaudeCode();
|
||||
}
|
||||
else
|
||||
{
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
RegisterWithClaudeCode(pythonDir);
|
||||
}
|
||||
}
|
||||
// Hide the picker once a valid binary is available
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true };
|
||||
string resolvedClaude = ExecPath.ResolveClaude();
|
||||
EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
}
|
||||
// CLI picker row (only when not found)
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (!claudeAvailable)
|
||||
{
|
||||
// Only show the picker button in not-found state (no redundant "not found" label)
|
||||
if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22)))
|
||||
{
|
||||
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, "");
|
||||
if (!string.IsNullOrEmpty(picked))
|
||||
{
|
||||
ExecPath.SetClaudeCliPath(picked);
|
||||
// Auto-register after setting a valid path
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
RegisterWithClaudeCode(pythonDir);
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GUILayout.Button($"Auto Configure", GUILayout.Height(32)))
|
||||
|
|
@ -1069,19 +1069,19 @@ namespace MCPForUnity.Editor.Windows
|
|||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
// Quick info (hide when Claude is not found to avoid confusion)
|
||||
bool hideConfigInfo =
|
||||
(mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude()))
|
||||
|| ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath()));
|
||||
if (!hideConfigInfo)
|
||||
{
|
||||
GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{
|
||||
fontSize = 10
|
||||
};
|
||||
EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle);
|
||||
}
|
||||
EditorGUILayout.Space(8);
|
||||
// Quick info (hide when Claude is not found to avoid confusion)
|
||||
bool hideConfigInfo =
|
||||
(mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude()))
|
||||
|| ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath()));
|
||||
if (!hideConfigInfo)
|
||||
{
|
||||
GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{
|
||||
fontSize = 10
|
||||
};
|
||||
EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle);
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleUnityBridge()
|
||||
|
|
@ -1099,52 +1099,52 @@ namespace MCPForUnity.Editor.Windows
|
|||
Repaint();
|
||||
}
|
||||
|
||||
private static bool IsValidUv(string path)
|
||||
{
|
||||
return !string.IsNullOrEmpty(path)
|
||||
&& System.IO.Path.IsPathRooted(path)
|
||||
&& System.IO.File.Exists(path);
|
||||
}
|
||||
private static bool IsValidUv(string path)
|
||||
{
|
||||
return !string.IsNullOrEmpty(path)
|
||||
&& System.IO.Path.IsPathRooted(path)
|
||||
&& System.IO.File.Exists(path);
|
||||
}
|
||||
|
||||
private static bool ValidateUvBinarySafe(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return false;
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
Arguments = "--version",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
using var p = System.Diagnostics.Process.Start(psi);
|
||||
if (p == null) return false;
|
||||
if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; }
|
||||
if (p.ExitCode != 0) return false;
|
||||
string output = p.StandardOutput.ReadToEnd().Trim();
|
||||
return output.StartsWith("uv ");
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
private static bool ValidateUvBinarySafe(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return false;
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
Arguments = "--version",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
using var p = System.Diagnostics.Process.Start(psi);
|
||||
if (p == null) return false;
|
||||
if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; }
|
||||
if (p.ExitCode != 0) return false;
|
||||
string output = p.StandardOutput.ReadToEnd().Trim();
|
||||
return output.StartsWith("uv ");
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static bool ArgsEqual(string[] a, string[] b)
|
||||
{
|
||||
if (a == null || b == null) return a == b;
|
||||
if (a.Length != b.Length) return false;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static bool ArgsEqual(string[] a, string[] b)
|
||||
{
|
||||
if (a == null || b == null) return a == b;
|
||||
if (a.Length != b.Length) return false;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null)
|
||||
{
|
||||
// 0) Respect explicit lock (hidden pref or UI toggle)
|
||||
try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { }
|
||||
// 0) Respect explicit lock (hidden pref or UI toggle)
|
||||
try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { }
|
||||
|
||||
JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented };
|
||||
|
||||
|
|
@ -1185,52 +1185,52 @@ namespace MCPForUnity.Editor.Windows
|
|||
existingConfig = new Newtonsoft.Json.Linq.JObject();
|
||||
}
|
||||
|
||||
// Determine existing entry references (command/args)
|
||||
string existingCommand = null;
|
||||
string[] existingArgs = null;
|
||||
bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode);
|
||||
try
|
||||
{
|
||||
if (isVSCode)
|
||||
{
|
||||
existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString();
|
||||
existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject<string[]>();
|
||||
}
|
||||
else
|
||||
{
|
||||
existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString();
|
||||
existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject<string[]>();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
// Determine existing entry references (command/args)
|
||||
string existingCommand = null;
|
||||
string[] existingArgs = null;
|
||||
bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode);
|
||||
try
|
||||
{
|
||||
if (isVSCode)
|
||||
{
|
||||
existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString();
|
||||
existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject<string[]>();
|
||||
}
|
||||
else
|
||||
{
|
||||
existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString();
|
||||
existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject<string[]>();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 1) Start from existing, only fill gaps (prefer trusted resolver)
|
||||
string uvPath = ServerInstaller.FindUvPath();
|
||||
// Optionally trust existingCommand if it looks like uv/uv.exe
|
||||
try
|
||||
{
|
||||
var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
|
||||
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
|
||||
{
|
||||
uvPath = existingCommand;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (uvPath == null) return "UV package manager not found. Please install UV first.";
|
||||
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
|
||||
// 1) Start from existing, only fill gaps (prefer trusted resolver)
|
||||
string uvPath = ServerInstaller.FindUvPath();
|
||||
// Optionally trust existingCommand if it looks like uv/uv.exe
|
||||
try
|
||||
{
|
||||
var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
|
||||
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
|
||||
{
|
||||
uvPath = existingCommand;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (uvPath == null) return "UV package manager not found. Please install UV first.";
|
||||
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
|
||||
|
||||
// 2) Canonical args order
|
||||
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
|
||||
// 2) Canonical args order
|
||||
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
|
||||
|
||||
// 3) Only write if changed
|
||||
bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal)
|
||||
|| !ArgsEqual(existingArgs, newArgs);
|
||||
if (!changed)
|
||||
{
|
||||
return "Configured successfully"; // nothing to do
|
||||
}
|
||||
// 3) Only write if changed
|
||||
bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal)
|
||||
|| !ArgsEqual(existingArgs, newArgs);
|
||||
if (!changed)
|
||||
{
|
||||
return "Configured successfully"; // nothing to do
|
||||
}
|
||||
|
||||
// 4) Ensure containers exist and write back minimal changes
|
||||
// 4) Ensure containers exist and write back minimal changes
|
||||
JObject existingRoot;
|
||||
if (existingConfig is JObject eo)
|
||||
existingRoot = eo;
|
||||
|
|
@ -1239,18 +1239,18 @@ namespace MCPForUnity.Editor.Windows
|
|||
|
||||
existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient);
|
||||
|
||||
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
|
||||
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
|
||||
|
||||
McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson);
|
||||
McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson);
|
||||
|
||||
try
|
||||
{
|
||||
if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
|
||||
UnityEditor.EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
|
||||
}
|
||||
catch { }
|
||||
try
|
||||
{
|
||||
if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
|
||||
UnityEditor.EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return "Configured successfully";
|
||||
return "Configured successfully";
|
||||
}
|
||||
|
||||
private void ShowManualConfigurationInstructions(
|
||||
|
|
@ -1264,23 +1264,23 @@ namespace MCPForUnity.Editor.Windows
|
|||
}
|
||||
|
||||
// New method to show manual instructions without changing status
|
||||
private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient)
|
||||
{
|
||||
// Get the Python directory path using Package Manager API
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
// Build manual JSON centrally using the shared builder
|
||||
string uvPathForManual = FindUvPath();
|
||||
if (uvPathForManual == null)
|
||||
{
|
||||
UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
|
||||
return;
|
||||
}
|
||||
private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient)
|
||||
{
|
||||
// Get the Python directory path using Package Manager API
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
// Build manual JSON centrally using the shared builder
|
||||
string uvPathForManual = FindUvPath();
|
||||
if (uvPathForManual == null)
|
||||
{
|
||||
UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
string manualConfig = mcpClient?.mcpType == McpTypes.Codex
|
||||
? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine
|
||||
: ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
|
||||
ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient);
|
||||
}
|
||||
string manualConfig = mcpClient?.mcpType == McpTypes.Codex
|
||||
? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine
|
||||
: ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
|
||||
ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient);
|
||||
}
|
||||
|
||||
private string FindPackagePythonDirectory()
|
||||
{
|
||||
|
|
@ -1311,25 +1311,25 @@ namespace MCPForUnity.Editor.Windows
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve via shared helper (handles local registry and older fallback) only if dev override on
|
||||
if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false))
|
||||
{
|
||||
if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded))
|
||||
{
|
||||
return embedded;
|
||||
}
|
||||
}
|
||||
// Resolve via shared helper (handles local registry and older fallback) only if dev override on
|
||||
if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false))
|
||||
{
|
||||
if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded))
|
||||
{
|
||||
return embedded;
|
||||
}
|
||||
}
|
||||
|
||||
// Log only if the resolved path does not actually contain server.py
|
||||
if (debugLogsEnabled)
|
||||
{
|
||||
bool hasServer = false;
|
||||
try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { }
|
||||
if (!hasServer)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path");
|
||||
}
|
||||
}
|
||||
// Log only if the resolved path does not actually contain server.py
|
||||
if (debugLogsEnabled)
|
||||
{
|
||||
bool hasServer = false;
|
||||
try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { }
|
||||
if (!hasServer)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -1368,12 +1368,12 @@ namespace MCPForUnity.Editor.Windows
|
|||
}
|
||||
}
|
||||
|
||||
private string ConfigureMcpClient(McpClient mcpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Determine the config file path based on OS
|
||||
string configPath;
|
||||
private string ConfigureMcpClient(McpClient mcpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Determine the config file path based on OS
|
||||
string configPath;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
|
|
@ -1401,23 +1401,23 @@ namespace MCPForUnity.Editor.Windows
|
|||
// Create directory if it doesn't exist
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(configPath));
|
||||
|
||||
// Find the server.py file location using the same logic as FindPackagePythonDirectory
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
// Find the server.py file location using the same logic as FindPackagePythonDirectory
|
||||
string pythonDir = FindPackagePythonDirectory();
|
||||
|
||||
if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py")))
|
||||
{
|
||||
ShowManualInstructionsWindow(configPath, mcpClient);
|
||||
return "Manual Configuration Required";
|
||||
}
|
||||
if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py")))
|
||||
{
|
||||
ShowManualInstructionsWindow(configPath, mcpClient);
|
||||
return "Manual Configuration Required";
|
||||
}
|
||||
|
||||
string result = mcpClient.mcpType == McpTypes.Codex
|
||||
? ConfigureCodexClient(pythonDir, configPath, mcpClient)
|
||||
: WriteToConfig(pythonDir, configPath, mcpClient);
|
||||
string result = mcpClient.mcpType == McpTypes.Codex
|
||||
? ConfigureCodexClient(pythonDir, configPath, mcpClient)
|
||||
: WriteToConfig(pythonDir, configPath, mcpClient);
|
||||
|
||||
// Update the client status after successful configuration
|
||||
if (result == "Configured successfully")
|
||||
{
|
||||
mcpClient.SetStatus(McpStatus.Configured);
|
||||
// Update the client status after successful configuration
|
||||
if (result == "Configured successfully")
|
||||
{
|
||||
mcpClient.SetStatus(McpStatus.Configured);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -1450,82 +1450,82 @@ namespace MCPForUnity.Editor.Windows
|
|||
$"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}"
|
||||
);
|
||||
return $"Failed to configure {mcpClient.name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient)
|
||||
{
|
||||
try { if (EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { }
|
||||
private string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient)
|
||||
{
|
||||
try { if (EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { }
|
||||
|
||||
string existingToml = string.Empty;
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
existingToml = File.ReadAllText(configPath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (debugLogsEnabled)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}");
|
||||
}
|
||||
existingToml = string.Empty;
|
||||
}
|
||||
}
|
||||
string existingToml = string.Empty;
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
existingToml = File.ReadAllText(configPath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (debugLogsEnabled)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}");
|
||||
}
|
||||
existingToml = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
string existingCommand = null;
|
||||
string[] existingArgs = null;
|
||||
if (!string.IsNullOrWhiteSpace(existingToml))
|
||||
{
|
||||
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
|
||||
}
|
||||
string existingCommand = null;
|
||||
string[] existingArgs = null;
|
||||
if (!string.IsNullOrWhiteSpace(existingToml))
|
||||
{
|
||||
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
|
||||
}
|
||||
|
||||
string uvPath = ServerInstaller.FindUvPath();
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
|
||||
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
|
||||
{
|
||||
uvPath = existingCommand;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
string uvPath = ServerInstaller.FindUvPath();
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
|
||||
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
|
||||
{
|
||||
uvPath = existingCommand;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (uvPath == null)
|
||||
{
|
||||
return "UV package manager not found. Please install UV first.";
|
||||
}
|
||||
if (uvPath == null)
|
||||
{
|
||||
return "UV package manager not found. Please install UV first.";
|
||||
}
|
||||
|
||||
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
|
||||
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
|
||||
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
|
||||
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
|
||||
|
||||
bool changed = true;
|
||||
if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null)
|
||||
{
|
||||
changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal)
|
||||
|| !ArgsEqual(existingArgs, newArgs);
|
||||
}
|
||||
bool changed = true;
|
||||
if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null)
|
||||
{
|
||||
changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal)
|
||||
|| !ArgsEqual(existingArgs, newArgs);
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
return "Configured successfully";
|
||||
}
|
||||
if (!changed)
|
||||
{
|
||||
return "Configured successfully";
|
||||
}
|
||||
|
||||
string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc);
|
||||
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock);
|
||||
string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc);
|
||||
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock);
|
||||
|
||||
McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml);
|
||||
McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml);
|
||||
|
||||
try
|
||||
{
|
||||
if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
|
||||
EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
|
||||
}
|
||||
catch { }
|
||||
try
|
||||
{
|
||||
if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
|
||||
EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return "Configured successfully";
|
||||
}
|
||||
return "Configured successfully";
|
||||
}
|
||||
|
||||
private void ShowCursorManualConfigurationInstructions(
|
||||
string configPath,
|
||||
|
|
@ -1657,36 +1657,36 @@ namespace MCPForUnity.Editor.Windows
|
|||
string[] args = null;
|
||||
bool configExists = false;
|
||||
|
||||
switch (mcpClient.mcpType)
|
||||
{
|
||||
case McpTypes.VSCode:
|
||||
dynamic config = JsonConvert.DeserializeObject(configJson);
|
||||
switch (mcpClient.mcpType)
|
||||
{
|
||||
case McpTypes.VSCode:
|
||||
dynamic config = JsonConvert.DeserializeObject(configJson);
|
||||
|
||||
// New schema: top-level servers
|
||||
if (config?.servers?.unityMCP != null)
|
||||
{
|
||||
args = config.servers.unityMCP.args.ToObject<string[]>();
|
||||
configExists = true;
|
||||
}
|
||||
// Back-compat: legacy mcp.servers
|
||||
else if (config?.mcp?.servers?.unityMCP != null)
|
||||
{
|
||||
args = config.mcp.servers.unityMCP.args.ToObject<string[]>();
|
||||
configExists = true;
|
||||
}
|
||||
break;
|
||||
// New schema: top-level servers
|
||||
if (config?.servers?.unityMCP != null)
|
||||
{
|
||||
args = config.servers.unityMCP.args.ToObject<string[]>();
|
||||
configExists = true;
|
||||
}
|
||||
// Back-compat: legacy mcp.servers
|
||||
else if (config?.mcp?.servers?.unityMCP != null)
|
||||
{
|
||||
args = config.mcp.servers.unityMCP.args.ToObject<string[]>();
|
||||
configExists = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case McpTypes.Codex:
|
||||
if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs))
|
||||
{
|
||||
args = codexArgs;
|
||||
configExists = true;
|
||||
}
|
||||
break;
|
||||
case McpTypes.Codex:
|
||||
if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs))
|
||||
{
|
||||
args = codexArgs;
|
||||
configExists = true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Standard MCP configuration check for Claude Desktop, Cursor, etc.
|
||||
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson);
|
||||
default:
|
||||
// Standard MCP configuration check for Claude Desktop, Cursor, etc.
|
||||
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson);
|
||||
|
||||
if (standardConfig?.mcpServers?.unityMCP != null)
|
||||
{
|
||||
|
|
@ -1812,30 +1812,30 @@ namespace MCPForUnity.Editor.Windows
|
|||
? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
: null; // On Windows, don't modify PATH - use system PATH as-is
|
||||
|
||||
// Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get <name>`
|
||||
string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" };
|
||||
List<string> existingNames = new List<string>();
|
||||
foreach (var candidate in candidateNamesForGet)
|
||||
{
|
||||
if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend))
|
||||
{
|
||||
// Success exit code indicates the server exists
|
||||
existingNames.Add(candidate);
|
||||
}
|
||||
}
|
||||
// Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get <name>`
|
||||
string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" };
|
||||
List<string> existingNames = new List<string>();
|
||||
foreach (var candidate in candidateNamesForGet)
|
||||
{
|
||||
if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend))
|
||||
{
|
||||
// Success exit code indicates the server exists
|
||||
existingNames.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (existingNames.Count == 0)
|
||||
{
|
||||
// Nothing to unregister – set status and bail early
|
||||
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
|
||||
if (claudeClient != null)
|
||||
{
|
||||
claudeClient.SetStatus(McpStatus.NotConfigured);
|
||||
UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister.");
|
||||
Repaint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (existingNames.Count == 0)
|
||||
{
|
||||
// Nothing to unregister – set status and bail early
|
||||
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
|
||||
if (claudeClient != null)
|
||||
{
|
||||
claudeClient.SetStatus(McpStatus.NotConfigured);
|
||||
UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister.");
|
||||
Repaint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try different possible server names
|
||||
string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import logging
|
||||
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
|
||||
try:
|
||||
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)
|
||||
_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.setLevel(getattr(logging, config.log_level))
|
||||
logger.addHandler(_fh)
|
||||
|
|
@ -42,7 +45,8 @@ except Exception:
|
|||
# Quieten noisy third-party loggers to avoid clutter during stdio handshake
|
||||
for noisy in ("httpx", "urllib3"):
|
||||
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:
|
||||
pass
|
||||
|
||||
|
|
@ -50,13 +54,11 @@ for noisy in ("httpx", "urllib3"):
|
|||
# Ensure a slightly higher telemetry timeout unless explicitly overridden by env
|
||||
try:
|
||||
|
||||
|
||||
# Ensure generous timeout unless explicitly overridden by env
|
||||
if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"):
|
||||
os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0"
|
||||
except Exception:
|
||||
pass
|
||||
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
|
||||
|
||||
# Global connection state
|
||||
_unity_connection: UnityConnection = None
|
||||
|
|
@ -79,6 +81,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
server_version = "unknown"
|
||||
# Defer initial telemetry by 1s to avoid stdio handshake interference
|
||||
import threading
|
||||
|
||||
def _emit_startup():
|
||||
try:
|
||||
record_telemetry(RecordType.STARTUP, {
|
||||
|
|
@ -91,9 +94,11 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
threading.Timer(1.0, _emit_startup).start()
|
||||
|
||||
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:
|
||||
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:
|
||||
_unity_connection = get_unity_connection()
|
||||
logger.info("Connected to Unity on startup")
|
||||
|
|
@ -124,7 +129,8 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
}
|
||||
)).start()
|
||||
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
|
||||
import threading as _t
|
||||
_err_msg = str(e)[:200]
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ def run_git(repo: pathlib.Path, *args: str) -> str:
|
|||
"git", "-C", str(repo), *args
|
||||
], capture_output=True, text=True)
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -77,7 +78,8 @@ def find_manifest(explicit: Optional[str]) -> pathlib.Path:
|
|||
candidate = parent / "Packages" / "manifest.json"
|
||||
if candidate.exists():
|
||||
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:
|
||||
|
|
@ -103,16 +105,21 @@ def build_options(repo_root: pathlib.Path, branch: str, origin_https: str):
|
|||
origin_remote = origin
|
||||
return [
|
||||
("[1] Upstream main", upstream),
|
||||
("[2] Remote current branch", f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"),
|
||||
("[3] Local workspace", f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"),
|
||||
("[2] Remote current branch",
|
||||
f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"),
|
||||
("[3] Local workspace",
|
||||
f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"),
|
||||
]
|
||||
|
||||
|
||||
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("--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")
|
||||
p.add_argument(
|
||||
"--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()
|
||||
|
||||
|
||||
|
|
@ -153,7 +160,8 @@ def main() -> None:
|
|||
data = read_json(manifest_path)
|
||||
deps = data.get("dependencies", {})
|
||||
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)
|
||||
|
||||
print(f"\nUpdating {PKG_NAME} → {chosen}")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
import socket, struct, json, sys
|
||||
import socket
|
||||
import struct
|
||||
import json
|
||||
import sys
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 6400
|
||||
|
|
@ -10,6 +13,7 @@ except (IndexError, ValueError):
|
|||
FILL = "R"
|
||||
MAX_FRAME = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def recv_exact(sock, n):
|
||||
buf = bytearray(n)
|
||||
view = memoryview(buf)
|
||||
|
|
@ -21,6 +25,7 @@ def recv_exact(sock, n):
|
|||
off += r
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def is_valid_json(b):
|
||||
try:
|
||||
json.loads(b.decode("utf-8"))
|
||||
|
|
@ -28,6 +33,7 @@ def is_valid_json(b):
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def recv_legacy_json(sock, timeout=60):
|
||||
sock.settimeout(timeout)
|
||||
chunks = []
|
||||
|
|
@ -45,6 +51,7 @@ def recv_legacy_json(sock, timeout=60):
|
|||
if is_valid_json(data):
|
||||
return data
|
||||
|
||||
|
||||
def main():
|
||||
# Cap filler to stay within framing limit (reserve small overhead for JSON)
|
||||
safe_max = max(1, MAX_FRAME - 4096)
|
||||
|
|
@ -83,16 +90,16 @@ def main():
|
|||
print(f"Response framed length: {resp_len}")
|
||||
MAX_RESP = MAX_FRAME
|
||||
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)
|
||||
else:
|
||||
s.sendall(body_bytes)
|
||||
resp = recv_legacy_json(s)
|
||||
|
||||
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__":
|
||||
main()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,4 +5,3 @@ import os
|
|||
os.environ.setdefault("DISABLE_TELEMETRY", "true")
|
||||
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
|
||||
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ sys.path.insert(0, str(SRC))
|
|||
mcp_pkg = types.ModuleType("mcp")
|
||||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
class _Dummy: pass
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
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.fastmcp", fastmcp_pkg)
|
||||
|
||||
|
||||
def _load(path: pathlib.Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self): self.tools = {}
|
||||
|
||||
def tool(self, *args, **kwargs):
|
||||
def deco(fn): self.tools[fn.__name__] = fn; return fn
|
||||
return deco
|
||||
|
||||
|
||||
def setup_tools():
|
||||
mcp = DummyMCP()
|
||||
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}},
|
||||
"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]
|
||||
e = p["edits"][0]
|
||||
assert e["startLine"] == 11 and e["startCol"] == 3
|
||||
|
|
@ -68,12 +79,14 @@ def test_normalizes_lsp_and_index_ranges(monkeypatch):
|
|||
calls.clear()
|
||||
edits = [{"range": [0, 0], "text": "// idx\n"}]
|
||||
# fake read to provide contents length
|
||||
|
||||
def fake_read(cmd, params):
|
||||
if params.get("action") == "read":
|
||||
return {"success": True, "data": {"contents": "hello\n"}}
|
||||
return {"success": True}
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -81,11 +94,13 @@ def test_noop_evidence_shape(monkeypatch):
|
|||
tools = setup_tools()
|
||||
apply = tools["apply_text_edits"]
|
||||
# Route response from Unity indicating no-op
|
||||
|
||||
def fake_send(cmd, params):
|
||||
return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}}
|
||||
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.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):
|
||||
tools_text = setup_tools()
|
||||
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
|
||||
sent = {}
|
||||
|
||||
def fake_send(cmd, params):
|
||||
if params.get("action") == "read":
|
||||
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 = [
|
||||
{"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
|
||||
# Last manage_script call should include options with applyMode atomic and validate relaxed
|
||||
last = sent["calls"][-1]
|
||||
assert last.get("options", {}).get("applyMode") == "atomic"
|
||||
assert last.get("options", {}).get("validate") == "relaxed"
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ sys.path.insert(0, str(SRC))
|
|||
mcp_pkg = types.ModuleType("mcp")
|
||||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
class _Dummy: pass
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
server_pkg.fastmcp = fastmcp_pkg
|
||||
|
|
@ -34,6 +39,7 @@ manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3")
|
|||
|
||||
class DummyMCP:
|
||||
def __init__(self): self.tools = {}
|
||||
|
||||
def tool(self, *args, **kwargs):
|
||||
def deco(fn): self.tools[fn.__name__] = fn; return fn
|
||||
return deco
|
||||
|
|
@ -56,13 +62,16 @@ def test_explicit_zero_based_normalized_warning(monkeypatch):
|
|||
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
|
||||
|
||||
# Explicit fields given as 0-based (invalid); SDK should normalize and warn
|
||||
edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}]
|
||||
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha")
|
||||
edits = [{"startLine": 0, "startCol": 0,
|
||||
"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
|
||||
data = resp.get("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]
|
||||
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)
|
||||
|
||||
edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}]
|
||||
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True)
|
||||
edits = [{"startLine": 0, "startCol": 0,
|
||||
"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.get("code") == "zero_based_explicit_fields"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
import sys
|
||||
import pathlib
|
||||
import importlib.util
|
||||
|
|
@ -9,7 +10,6 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|||
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
|
||||
class DummyMCP:
|
||||
def __init__(self):
|
||||
|
|
@ -21,12 +21,14 @@ class DummyMCP:
|
|||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def resource_tools():
|
||||
mcp = DummyMCP()
|
||||
register_resource_tools(mcp)
|
||||
return mcp.tools
|
||||
|
||||
|
||||
def test_find_in_file_returns_positions(resource_tools, tmp_path):
|
||||
proj = tmp_path
|
||||
assets = proj / "Assets"
|
||||
|
|
@ -37,9 +39,11 @@ def test_find_in_file_returns_positions(resource_tools, tmp_path):
|
|||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
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:
|
||||
loop.close()
|
||||
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}]
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
server_pkg.fastmcp = fastmcp_pkg
|
||||
|
|
@ -32,7 +34,8 @@ def _load_module(path: pathlib.Path, name: str):
|
|||
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:
|
||||
|
|
@ -72,4 +75,3 @@ def test_get_sha_param_shape_and_routing(monkeypatch):
|
|||
assert captured["params"]["path"].endswith("Assets/Scripts")
|
||||
assert resp["success"] is True
|
||||
assert resp["data"] == {"sha256": "abc", "lengthBytes": 1}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
server_pkg.fastmcp = fastmcp_pkg
|
||||
|
|
@ -28,13 +30,17 @@ sys.modules.setdefault("mcp", mcp_pkg)
|
|||
sys.modules.setdefault("mcp.server", server_pkg)
|
||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
||||
|
||||
|
||||
def load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(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():
|
||||
"""Test that our improved anchor matching finds the right closing brace."""
|
||||
|
|
@ -69,7 +75,9 @@ public class TestClass : MonoBehaviour
|
|||
match_pos = best_match.start()
|
||||
line_num = test_code[:match_pos].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():
|
||||
"""Compare old vs new matching behavior."""
|
||||
|
|
@ -104,18 +112,22 @@ public class TestClass : MonoBehaviour
|
|||
|
||||
# Old behavior (first match)
|
||||
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_match = manage_script_edits_module._find_best_anchor_match(
|
||||
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 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
|
||||
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():
|
||||
"""Test that _apply_edits_locally uses improved matching."""
|
||||
|
|
@ -140,14 +152,17 @@ public class TestClass : MonoBehaviour
|
|||
"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')
|
||||
try:
|
||||
idx = next(i for i, line in enumerate(lines) if "NewMethod" in line)
|
||||
except StopIteration:
|
||||
assert False, "NewMethod not found in result"
|
||||
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__":
|
||||
print("Testing improved anchor matching...")
|
||||
|
|
|
|||
|
|
@ -64,5 +64,7 @@ def test_no_print_statements_in_codebase():
|
|||
v.visit(tree)
|
||||
if v.hit:
|
||||
offenders.append(py_file.relative_to(SRC))
|
||||
assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors)
|
||||
assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders)
|
||||
assert not syntax_errors, "syntax errors in: " + \
|
||||
", ".join(str(e) for e in syntax_errors)
|
||||
assert not offenders, "stdout writes found in: " + \
|
||||
", ".join(str(o) for o in offenders)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import tools.manage_script as manage_script # type: ignore
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
|
@ -5,7 +6,6 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
|
||||
|
||||
# Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
candidates = [
|
||||
|
|
@ -25,7 +25,12 @@ sys.path.insert(0, str(SRC))
|
|||
mcp_pkg = types.ModuleType("mcp")
|
||||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
class _Dummy: pass
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
server_pkg.fastmcp = fastmcp_pkg
|
||||
|
|
@ -36,7 +41,6 @@ sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|||
|
||||
|
||||
# Import target module after path injection
|
||||
import tools.manage_script as manage_script # type: ignore
|
||||
|
||||
|
||||
class DummyMCP:
|
||||
|
|
@ -83,10 +87,13 @@ def test_split_uri_unity_path(monkeypatch):
|
|||
@pytest.mark.parametrize(
|
||||
"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:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", "Hello", "Assets/Scripts"),
|
||||
("file:///tmp/Other.cs", "Other", "tmp"), # outside Assets → fall back to normalized dir
|
||||
("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs",
|
||||
"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):
|
||||
|
|
@ -118,9 +125,8 @@ def test_split_uri_plain_path(monkeypatch):
|
|||
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
|
||||
|
||||
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']['path'] == 'Assets/Scripts'
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
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.fastmcp", fastmcp_pkg)
|
||||
|
||||
|
||||
def _load_module(path: pathlib.Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(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:
|
||||
def __init__(self):
|
||||
|
|
@ -41,11 +47,13 @@ class DummyMCP:
|
|||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
def setup_tools():
|
||||
mcp = DummyMCP()
|
||||
read_console_mod.register_read_console_tools(mcp)
|
||||
return mcp.tools
|
||||
|
||||
|
||||
def test_read_console_full_default(monkeypatch):
|
||||
tools = setup_tools()
|
||||
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, "get_unity_connection", lambda: object())
|
||||
monkeypatch.setattr(
|
||||
read_console_mod, "get_unity_connection", lambda: object())
|
||||
|
||||
resp = read_console(ctx=None, count=10)
|
||||
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, "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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
import sys
|
||||
import pathlib
|
||||
import asyncio
|
||||
|
|
@ -13,9 +14,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
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.fastmcp", fastmcp_pkg)
|
||||
|
||||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
|
||||
|
||||
class DummyMCP:
|
||||
def __init__(self):
|
||||
|
|
@ -57,7 +58,8 @@ def test_read_resource_minimal_metadata_only(resource_tools, tmp_path):
|
|||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
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:
|
||||
loop.close()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
import pytest
|
||||
|
||||
|
||||
|
|
@ -21,17 +22,18 @@ if SRC is None:
|
|||
)
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
|
||||
class DummyMCP:
|
||||
def __init__(self):
|
||||
self._tools = {}
|
||||
|
||||
def tool(self, *args, **kwargs): # accept kwargs like description
|
||||
def deco(fn):
|
||||
self._tools[fn.__name__] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def resource_tools():
|
||||
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
|
||||
import asyncio
|
||||
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
|
||||
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"]
|
||||
import asyncio
|
||||
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 "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", "")
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
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.fastmcp", fastmcp_pkg)
|
||||
|
||||
|
||||
def load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(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:
|
||||
|
|
@ -46,16 +52,19 @@ class DummyMCP:
|
|||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def setup_manage_script():
|
||||
mcp = DummyMCP()
|
||||
manage_script_module.register_manage_script_tools(mcp)
|
||||
return mcp.tools
|
||||
|
||||
|
||||
def setup_manage_asset():
|
||||
mcp = DummyMCP()
|
||||
manage_asset_module.register_manage_asset_tools(mcp)
|
||||
return mcp.tools
|
||||
|
||||
|
||||
def test_apply_text_edits_long_file(monkeypatch):
|
||||
tools = setup_manage_script()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
|
|
@ -66,15 +75,18 @@ def test_apply_text_edits_long_file(monkeypatch):
|
|||
captured["params"] = params
|
||||
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])
|
||||
assert captured["cmd"] == "manage_script"
|
||||
assert captured["params"]["action"] == "apply_text_edits"
|
||||
assert captured["params"]["edits"][0]["startLine"] == 1005
|
||||
assert resp["success"] is True
|
||||
|
||||
|
||||
def test_sequential_edits_use_precondition(monkeypatch):
|
||||
tools = setup_manage_script()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
|
|
@ -84,12 +96,16 @@ def test_sequential_edits_use_precondition(monkeypatch):
|
|||
calls.append(params)
|
||||
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])
|
||||
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"}
|
||||
resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"])
|
||||
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2,
|
||||
"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 resp2["sha256"] == "hash2"
|
||||
|
|
@ -104,10 +120,12 @@ def test_apply_text_edits_forwards_options(monkeypatch):
|
|||
captured["params"] = params
|
||||
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"}
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -120,16 +138,20 @@ def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
|
|||
captured["params"] = params
|
||||
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 = [
|
||||
{"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", {})
|
||||
assert opts.get("applyMode") == "atomic"
|
||||
|
||||
|
||||
def test_manage_asset_prefab_modify_request(monkeypatch):
|
||||
tools = setup_manage_asset()
|
||||
manage_asset = tools["manage_asset"]
|
||||
|
|
@ -140,8 +162,10 @@ def test_manage_asset_prefab_modify_request(monkeypatch):
|
|||
captured["params"] = params
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async)
|
||||
monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object())
|
||||
monkeypatch.setattr(manage_asset_module,
|
||||
"async_send_command_with_retry", fake_async)
|
||||
monkeypatch.setattr(manage_asset_module,
|
||||
"get_unity_connection", lambda: object())
|
||||
|
||||
async def run():
|
||||
resp = await manage_asset(
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import os
|
||||
import importlib
|
||||
|
||||
|
||||
def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
|
||||
# Point data dir to temp to avoid touching real files
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
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)
|
||||
|
||||
tc = telemetry.TelemetryCollector()
|
||||
# Should have fallen back to default endpoint
|
||||
assert tc.config.endpoint == tc.config.default_endpoint
|
||||
|
||||
|
||||
def test_config_preferred_then_env_override(tmp_path, monkeypatch):
|
||||
# Simulate config telemetry endpoint
|
||||
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
|
||||
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
|
||||
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
|
||||
try:
|
||||
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry")
|
||||
telemetry = importlib.import_module(
|
||||
"UnityMcpBridge.UnityMcpServer~.src.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
tc = telemetry.TelemetryCollector()
|
||||
assert tc.config.endpoint == "https://example.com/telemetry"
|
||||
|
||||
# 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)
|
||||
tc2 = telemetry.TelemetryCollector()
|
||||
assert tc2.config.endpoint == "https://override.example/ep"
|
||||
finally:
|
||||
cfg_mod.config.telemetry_endpoint = old_endpoint
|
||||
|
||||
|
||||
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
|
||||
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)
|
||||
|
||||
tc1 = telemetry.TelemetryCollector()
|
||||
|
|
@ -53,4 +61,3 @@ def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
|
|||
importlib.reload(telemetry)
|
||||
tc2 = telemetry.TelemetryCollector()
|
||||
assert tc2._customer_uuid == first_uuid
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
server_pkg.fastmcp = fastmcp_pkg
|
||||
|
|
@ -72,12 +74,12 @@ def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
|
|||
time.sleep(0.3)
|
||||
|
||||
# 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
|
||||
|
||||
# Ensure only one worker thread exists and 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import importlib
|
|||
|
||||
def _get_decorator_module():
|
||||
# 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
|
||||
|
||||
|
||||
|
|
@ -79,5 +80,3 @@ def test_subaction_none_when_not_present(monkeypatch):
|
|||
_ = wrapped(None, name="X")
|
||||
assert captured["tool_name"] == "apply_text_edits"
|
||||
assert captured["sub_action"] is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from unity_connection import UnityConnection
|
||||
import sys
|
||||
import json
|
||||
import struct
|
||||
|
|
@ -24,8 +25,6 @@ if SRC is None:
|
|||
)
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
from unity_connection import UnityConnection
|
||||
|
||||
|
||||
def start_dummy_server(greeting: bytes, respond_ping: bool = False):
|
||||
"""Start a minimal TCP server for handshake tests."""
|
||||
|
|
@ -159,7 +158,10 @@ def test_unframed_data_disconnect():
|
|||
|
||||
def test_zero_length_payload_heartbeat():
|
||||
# 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.bind(("127.0.0.1", 0))
|
||||
|
|
@ -181,8 +183,10 @@ def test_zero_length_payload_heartbeat():
|
|||
conn.sendall(struct.pack(">Q", len(payload)) + payload)
|
||||
time.sleep(0.02)
|
||||
finally:
|
||||
try: conn.close()
|
||||
except Exception: pass
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ mcp_pkg = types.ModuleType("mcp")
|
|||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
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.fastmcp", fastmcp_pkg)
|
||||
|
||||
|
||||
def _load_module(path: pathlib.Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(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:
|
||||
def __init__(self):
|
||||
|
|
@ -41,11 +47,13 @@ class DummyMCP:
|
|||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
def setup_tools():
|
||||
mcp = DummyMCP()
|
||||
manage_script.register_manage_script_tools(mcp)
|
||||
return mcp.tools
|
||||
|
||||
|
||||
def test_validate_script_returns_counts(monkeypatch):
|
||||
tools = setup_tools()
|
||||
validate_script = tools["validate_script"]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ def dlog(*args):
|
|||
|
||||
def find_status_files() -> list[Path]:
|
||||
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():
|
||||
return []
|
||||
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:
|
||||
# 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")
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
# Send a quick ping first
|
||||
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.
|
||||
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:
|
||||
# Derive name and directory for ManageScript and compute precondition SHA + EOF position
|
||||
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
|
||||
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"))
|
||||
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
|
||||
|
||||
read_obj = json.loads(resp.decode("utf-8", errors="ignore"))
|
||||
result = read_obj.get("result", read_obj) if isinstance(read_obj, dict) else {}
|
||||
read_obj = json.loads(
|
||||
resp.decode("utf-8", errors="ignore"))
|
||||
result = read_obj.get("result", read_obj) if isinstance(
|
||||
read_obj, dict) else {}
|
||||
if result.get("success"):
|
||||
data_obj = result.get("data", {})
|
||||
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
|
||||
|
||||
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)
|
||||
continue
|
||||
|
||||
# Compute SHA and EOF insertion point
|
||||
import hashlib
|
||||
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
|
||||
sha = hashlib.sha256(
|
||||
contents.encode("utf-8")).hexdigest()
|
||||
lines = contents.splitlines(keepends=True)
|
||||
# Insert at true EOF (safe against header guards)
|
||||
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
|
||||
marker = f"// MCP_STRESS seq={seq} time={int(time.time())}"
|
||||
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
|
||||
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"))
|
||||
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
|
||||
try:
|
||||
data = json.loads(resp.decode("utf-8", errors="ignore"))
|
||||
result = data.get("result", data) if isinstance(data, dict) else {}
|
||||
data = json.loads(resp.decode(
|
||||
"utf-8", errors="ignore"))
|
||||
result = data.get("result", data) if isinstance(
|
||||
data, dict) else {}
|
||||
ok = bool(result.get("success", False))
|
||||
if ok:
|
||||
stats["applies"] = stats.get("applies", 0) + 1
|
||||
stats["applies"] = stats.get(
|
||||
"applies", 0) + 1
|
||||
apply_success = True
|
||||
break
|
||||
except Exception:
|
||||
|
|
@ -290,7 +302,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
|
|||
except Exception:
|
||||
pass
|
||||
if not apply_success:
|
||||
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
|
||||
stats["apply_errors"] = stats.get(
|
||||
"apply_errors", 0) + 1
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -298,13 +311,17 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str
|
|||
|
||||
|
||||
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("--project", default=str(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("--project", default=str(
|
||||
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("--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()
|
||||
|
||||
port = discover_port(args.project)
|
||||
|
|
@ -315,10 +332,12 @@ async def main():
|
|||
|
||||
# Spawn 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
|
||||
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)
|
||||
print(json.dumps({"port": port, "stats": stats}, indent=2))
|
||||
|
|
@ -329,5 +348,3 @@ if __name__ == "__main__":
|
|||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue