Skip to content

Commit c0fb8ee

Browse files
committed
Dev: add tools/stress_mcp.py stress utility and document usage in README-DEV
1 parent 3446c2e commit c0fb8ee

File tree

2 files changed

+251
-0
lines changed

2 files changed

+251
-0
lines changed

README-DEV.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,38 @@ To find it reliably:
6666

6767
Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server.
6868

69+
## MCP Bridge Stress Test
70+
71+
An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering periodic asset refreshes and script reloads.
72+
73+
### Script
74+
- `tools/stress_mcp.py`
75+
76+
### What it does
77+
- Starts N TCP clients against the Unity MCP bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`).
78+
- Sends a mix of framed `ping`, `execute_menu_item` (e.g., `Assets/Refresh`), and small `manage_gameobject` requests.
79+
- In parallel, toggles a comment in a large C# file to encourage domain reloads, and triggers `Assets/Refresh` via MCP.
80+
81+
### Usage (local)
82+
```bash
83+
python3 tools/stress_mcp.py --duration 60 --clients 8
84+
```
85+
86+
Flags:
87+
- `--project` Unity project path (auto-detected to the included test project by default)
88+
- `--unity-file` C# file to toggle (defaults to the long test script)
89+
- `--clients` number of concurrent clients (default 10)
90+
- `--duration` seconds to run (default 60)
91+
92+
Expected outcome:
93+
- No Unity Editor crashes during reload churn
94+
- Clients reconnect cleanly after reloads
95+
- Script prints a JSON summary of request counts and disconnects
96+
97+
CI guidance:
98+
- Keep this out of default PR CI due to Unity/editor requirements and runtime variability.
99+
- Optionally run it as a manual workflow or nightly job on a Unity-capable runner.
100+
69101
## CI Test Workflow (GitHub Actions)
70102

71103
We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge.

tools/stress_mcp.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env python3
2+
import asyncio
3+
import argparse
4+
import json
5+
import os
6+
import random
7+
import socket
8+
import struct
9+
import sys
10+
import time
11+
from pathlib import Path
12+
13+
14+
def find_status_files() -> list[Path]:
15+
home = Path.home()
16+
status_dir = Path(os.environ.get("UNITY_MCP_STATUS_DIR", home / ".unity-mcp"))
17+
if not status_dir.exists():
18+
return []
19+
return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
20+
21+
22+
def discover_port(project_path: str | None) -> int:
23+
# Default bridge port if nothing found
24+
default_port = 6400
25+
files = find_status_files()
26+
for f in files:
27+
try:
28+
data = json.loads(f.read_text())
29+
port = int(data.get("unity_port", 0) or 0)
30+
proj = data.get("project_path") or ""
31+
if project_path:
32+
# Match status for the given project if possible
33+
if proj and project_path in proj:
34+
if 0 < port < 65536:
35+
return port
36+
else:
37+
if 0 < port < 65536:
38+
return port
39+
except Exception:
40+
pass
41+
return default_port
42+
43+
44+
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
45+
buf = b""
46+
while len(buf) < n:
47+
chunk = await reader.read(n - len(buf))
48+
if not chunk:
49+
raise ConnectionError("Connection closed while reading")
50+
buf += chunk
51+
return buf
52+
53+
54+
async def read_frame(reader: asyncio.StreamReader) -> bytes:
55+
header = await read_exact(reader, 8)
56+
(length,) = struct.unpack(">Q", header)
57+
if length <= 0 or length > (64 * 1024 * 1024):
58+
raise ValueError(f"Invalid frame length: {length}")
59+
return await read_exact(reader, length)
60+
61+
62+
async def write_frame(writer: asyncio.StreamWriter, payload: bytes) -> None:
63+
header = struct.pack(">Q", len(payload))
64+
writer.write(header)
65+
writer.write(payload)
66+
await writer.drain()
67+
68+
69+
async def do_handshake(reader: asyncio.StreamReader) -> None:
70+
# Server sends a single line handshake: "WELCOME UNITY-MCP 1 FRAMING=1\n"
71+
line = await reader.readline()
72+
if not line or b"WELCOME UNITY-MCP" not in line:
73+
raise ConnectionError(f"Unexpected handshake from server: {line!r}")
74+
75+
76+
def make_ping_frame() -> bytes:
77+
return b"ping"
78+
79+
80+
def make_execute_menu_item(menu_path: str) -> bytes:
81+
payload = {
82+
"type": "execute_menu_item",
83+
"params": {"action": "execute", "menu_path": menu_path},
84+
}
85+
return json.dumps(payload).encode("utf-8")
86+
87+
88+
def make_manage_gameobject_modify_dummy(target_name: str) -> bytes:
89+
payload = {
90+
"type": "manage_gameobject",
91+
"params": {
92+
"action": "modify",
93+
"target": target_name,
94+
"search_method": "by_name",
95+
# Intentionally small and sometimes invalid to exercise error paths safely
96+
"componentProperties": {
97+
"Transform": {"localScale": {"x": 1.0, "y": 1.0, "z": 1.0}},
98+
"Rigidbody": {"velocity": "invalid_type"},
99+
},
100+
},
101+
}
102+
return json.dumps(payload).encode("utf-8")
103+
104+
105+
async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: dict):
106+
reconnect_delay = 0.2
107+
while time.time() < stop_time:
108+
try:
109+
reader, writer = await asyncio.open_connection(host, port)
110+
await do_handshake(reader)
111+
# Send a quick ping first
112+
await write_frame(writer, make_ping_frame())
113+
_ = await read_frame(reader) # ignore content
114+
115+
# Main activity loop
116+
while time.time() < stop_time:
117+
r = random.random()
118+
if r < 0.70:
119+
# Ping
120+
await write_frame(writer, make_ping_frame())
121+
_ = await read_frame(reader)
122+
stats["pings"] += 1
123+
elif r < 0.90:
124+
# Lightweight menu execute: Assets/Refresh
125+
await write_frame(writer, make_execute_menu_item("Assets/Refresh"))
126+
_ = await read_frame(reader)
127+
stats["menus"] += 1
128+
else:
129+
# Small manage_gameobject request (may legitimately error if target not found)
130+
await write_frame(writer, make_manage_gameobject_modify_dummy("__MCP_Stress_Object__"))
131+
_ = await read_frame(reader)
132+
stats["mods"] += 1
133+
134+
await asyncio.sleep(0.01)
135+
136+
except (ConnectionError, OSError, asyncio.IncompleteReadError):
137+
stats["disconnects"] += 1
138+
await asyncio.sleep(reconnect_delay)
139+
reconnect_delay = min(reconnect_delay * 1.5, 2.0)
140+
continue
141+
except Exception:
142+
stats["errors"] += 1
143+
await asyncio.sleep(0.2)
144+
continue
145+
finally:
146+
try:
147+
writer.close() # type: ignore
148+
await writer.wait_closed() # type: ignore
149+
except Exception:
150+
pass
151+
152+
153+
async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int):
154+
# Toggle a comment in a large .cs file to force a recompilation; then request Assets/Refresh
155+
path = Path(unity_file) if unity_file else None
156+
toggle = True
157+
while time.time() < stop_time:
158+
try:
159+
if path and path.exists():
160+
s = path.read_text(encoding="utf-8", errors="ignore")
161+
marker_on = "// MCP_STRESS_ON"
162+
marker_off = "// MCP_STRESS_OFF"
163+
if toggle:
164+
if marker_on not in s:
165+
path.write_text(s + ("\n" if not s.endswith("\n") else "") + marker_on + "\n", encoding="utf-8")
166+
else:
167+
if marker_off not in s:
168+
path.write_text(s + ("\n" if not s.endswith("\n") else "") + marker_off + "\n", encoding="utf-8")
169+
toggle = not toggle
170+
171+
# Ask Unity to refresh assets (safe, Editor main thread)
172+
try:
173+
reader, writer = await asyncio.open_connection(host, port)
174+
await do_handshake(reader)
175+
await write_frame(writer, make_execute_menu_item("Assets/Refresh"))
176+
_ = await read_frame(reader)
177+
writer.close()
178+
await writer.wait_closed()
179+
except Exception:
180+
pass
181+
182+
except Exception:
183+
pass
184+
await asyncio.sleep(10.0)
185+
186+
187+
async def main():
188+
ap = argparse.ArgumentParser(description="Stress test the Unity MCP bridge with concurrent clients and reload churn")
189+
ap.add_argument("--host", default="127.0.0.1")
190+
ap.add_argument("--project", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests"))
191+
ap.add_argument("--unity-file", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs"))
192+
ap.add_argument("--clients", type=int, default=10)
193+
ap.add_argument("--duration", type=int, default=60)
194+
args = ap.parse_args()
195+
196+
port = discover_port(args.project)
197+
stop_time = time.time() + max(10, args.duration)
198+
199+
stats = {"pings": 0, "menus": 0, "mods": 0, "disconnects": 0, "errors": 0}
200+
tasks = []
201+
202+
# Spawn clients
203+
for i in range(max(1, args.clients)):
204+
tasks.append(asyncio.create_task(client_loop(i, args.host, port, stop_time, stats)))
205+
206+
# Spawn reload churn task
207+
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port)))
208+
209+
await asyncio.gather(*tasks, return_exceptions=True)
210+
print(json.dumps({"port": port, "stats": stats}, indent=2))
211+
212+
213+
if __name__ == "__main__":
214+
try:
215+
asyncio.run(main())
216+
except KeyboardInterrupt:
217+
pass
218+
219+

0 commit comments

Comments
 (0)