mcpforunityserver 9.0.8__py3-none-any.whl → 9.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +174 -60
  26. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/METADATA +3 -2
  27. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/RECORD +37 -14
  28. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/top_level.txt +1 -1
  31. services/custom_tool_service.py +168 -13
  32. services/resources/__init__.py +6 -1
  33. services/tools/__init__.py +6 -1
  34. services/tools/refresh_unity.py +66 -16
  35. transport/legacy/unity_connection.py +26 -8
  36. transport/plugin_hub.py +17 -0
  37. __init__.py +0 -0
  38. {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/licenses/LICENSE +0 -0
main.py CHANGED
@@ -1,3 +1,18 @@
1
+ from starlette.requests import Request
2
+ from transport.unity_instance_middleware import (
3
+ UnityInstanceMiddleware,
4
+ get_unity_instance_middleware
5
+ )
6
+ from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool
7
+ from services.tools import register_all_tools
8
+ from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version
9
+ from services.resources import register_all_resources
10
+ from transport.plugin_registry import PluginRegistry
11
+ from transport.plugin_hub import PluginHub
12
+ from services.custom_tool_service import CustomToolService
13
+ from core.config import config
14
+ from starlette.routing import WebSocketRoute
15
+ from starlette.responses import JSONResponse
1
16
  import argparse
2
17
  import asyncio
3
18
  import logging
@@ -37,22 +52,20 @@ except Exception:
37
52
 
38
53
  from fastmcp import FastMCP
39
54
  from logging.handlers import RotatingFileHandler
40
- from starlette.requests import Request
41
- from starlette.responses import JSONResponse
42
- from starlette.routing import WebSocketRoute
43
55
 
44
- from core.config import config
45
- from services.custom_tool_service import CustomToolService
46
- from transport.plugin_hub import PluginHub
47
- from transport.plugin_registry import PluginRegistry
48
- from services.resources import register_all_resources
49
- from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version
50
- from services.tools import register_all_tools
51
- from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool
52
- from transport.unity_instance_middleware import (
53
- UnityInstanceMiddleware,
54
- get_unity_instance_middleware
55
- )
56
+
57
+ class WindowsSafeRotatingFileHandler(RotatingFileHandler):
58
+ """RotatingFileHandler that gracefully handles Windows file locking during rotation."""
59
+
60
+ def doRollover(self):
61
+ """Override to catch PermissionError on Windows when log file is locked."""
62
+ try:
63
+ super().doRollover()
64
+ except PermissionError:
65
+ # On Windows, another process may have the log file open.
66
+ # Skip rotation this time - we'll try again on the next rollover.
67
+ pass
68
+
56
69
 
57
70
  # Configure logging using settings from config
58
71
  logging.basicConfig(
@@ -69,7 +82,7 @@ try:
69
82
  "~/Library/Application Support/UnityMCP"), "Logs")
70
83
  os.makedirs(_log_dir, exist_ok=True)
71
84
  _file_path = os.path.join(_log_dir, "unity_mcp_server.log")
72
- _fh = RotatingFileHandler(
85
+ _fh = WindowsSafeRotatingFileHandler(
73
86
  _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
74
87
  _fh.setFormatter(logging.Formatter(config.log_format))
75
88
  _fh.setLevel(getattr(logging, config.log_level))
@@ -228,14 +241,23 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
228
241
  _unity_connection_pool.disconnect_all()
229
242
  logger.info("MCP for Unity Server shut down")
230
243
 
231
- # Initialize MCP server
232
- mcp = FastMCP(
233
- name="mcp-for-unity-server",
234
- lifespan=server_lifespan,
235
- instructions="""
244
+
245
+ def _build_instructions(project_scoped_tools: bool) -> str:
246
+ if project_scoped_tools:
247
+ custom_tools_note = (
248
+ "I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first "
249
+ "to see what special capabilities are available for the current project."
250
+ )
251
+ else:
252
+ custom_tools_note = (
253
+ "Custom tools are registered as standard tools when Unity connects. "
254
+ "No project-scoped custom tools resource is available."
255
+ )
256
+
257
+ return f"""
236
258
  This server provides tools to interact with the Unity Game Engine Editor.
237
259
 
238
- I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first to see what special capabilities are available for the current project.
260
+ {custom_tools_note}
239
261
 
240
262
  Targeting Unity instances:
241
263
  - Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
@@ -282,46 +304,123 @@ Payload sizing & paging (important):
282
304
  - Use paging (`page_size`, `page_number`) and keep `page_size` modest (e.g. **25-50**) to avoid token-heavy responses.
283
305
  - Keep `generate_preview=false` unless you explicitly need thumbnails (previews may include large base64 payloads).
284
306
  """
285
- )
286
-
287
- custom_tool_service = CustomToolService(mcp)
288
-
289
307
 
290
- @mcp.custom_route("/health", methods=["GET"])
291
- async def health_http(_: Request) -> JSONResponse:
292
- return JSONResponse({
293
- "status": "healthy",
294
- "timestamp": time.time(),
295
- "message": "MCP for Unity server is running"
296
- })
297
-
298
-
299
- @mcp.custom_route("/plugin/sessions", methods=["GET"])
300
- async def plugin_sessions_route(_: Request) -> JSONResponse:
301
- data = await PluginHub.get_sessions()
302
- return JSONResponse(data.model_dump())
303
308
 
309
+ def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
310
+ mcp = FastMCP(
311
+ name="mcp-for-unity-server",
312
+ lifespan=server_lifespan,
313
+ instructions=_build_instructions(project_scoped_tools),
314
+ )
304
315
 
305
- # Initialize and register middleware for session-based Unity instance routing
306
- # Using the singleton getter ensures we use the same instance everywhere
307
- unity_middleware = get_unity_instance_middleware()
308
- mcp.add_middleware(unity_middleware)
309
- logger.info("Registered Unity instance middleware for session-based routing")
316
+ global custom_tool_service
317
+ custom_tool_service = CustomToolService(
318
+ mcp, project_scoped_tools=project_scoped_tools)
319
+
320
+ @mcp.custom_route("/health", methods=["GET"])
321
+ async def health_http(_: Request) -> JSONResponse:
322
+ return JSONResponse({
323
+ "status": "healthy",
324
+ "timestamp": time.time(),
325
+ "message": "MCP for Unity server is running"
326
+ })
327
+
328
+ @mcp.custom_route("/api/command", methods=["POST"])
329
+ async def cli_command_route(request: Request) -> JSONResponse:
330
+ """REST endpoint for CLI commands to Unity."""
331
+ try:
332
+ body = await request.json()
333
+
334
+ command_type = body.get("type")
335
+ params = body.get("params", {})
336
+ unity_instance = body.get("unity_instance")
337
+
338
+ if not command_type:
339
+ return JSONResponse({"success": False, "error": "Missing 'type' field"}, status_code=400)
340
+
341
+ # Get available sessions
342
+ sessions = await PluginHub.get_sessions()
343
+ if not sessions.sessions:
344
+ return JSONResponse({
345
+ "success": False,
346
+ "error": "No Unity instances connected. Make sure Unity is running with MCP plugin."
347
+ }, status_code=503)
348
+
349
+ # Find target session
350
+ session_id = None
351
+ if unity_instance:
352
+ # Try to match by hash or project name
353
+ for sid, details in sessions.sessions.items():
354
+ if details.hash == unity_instance or details.project == unity_instance:
355
+ session_id = sid
356
+ break
357
+
358
+ # If a specific unity_instance was requested but not found, return an error
359
+ if not session_id:
360
+ return JSONResponse(
361
+ {
362
+ "success": False,
363
+ "error": f"Unity instance '{unity_instance}' not found",
364
+ },
365
+ status_code=404,
366
+ )
367
+ else:
368
+ # No specific unity_instance requested: use first available session
369
+ session_id = next(iter(sessions.sessions.keys()))
310
370
 
311
- # Mount plugin websocket hub at /hub/plugin when HTTP transport is active
312
- existing_routes = [
313
- route for route in mcp._get_additional_http_routes()
314
- if isinstance(route, WebSocketRoute) and route.path == "/hub/plugin"
315
- ]
316
- if not existing_routes:
317
- mcp._additional_http_routes.append(
318
- WebSocketRoute("/hub/plugin", PluginHub))
371
+ # Send command to Unity
372
+ result = await PluginHub.send_command(session_id, command_type, params)
373
+ return JSONResponse(result)
319
374
 
320
- # Register all tools
321
- register_all_tools(mcp)
375
+ except Exception as e:
376
+ logger.error(f"CLI command error: {e}")
377
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
322
378
 
323
- # Register all resources
324
- register_all_resources(mcp)
379
+ @mcp.custom_route("/api/instances", methods=["GET"])
380
+ async def cli_instances_route(_: Request) -> JSONResponse:
381
+ """REST endpoint to list connected Unity instances."""
382
+ try:
383
+ sessions = await PluginHub.get_sessions()
384
+ instances = []
385
+ for session_id, details in sessions.sessions.items():
386
+ instances.append({
387
+ "session_id": session_id,
388
+ "project": details.project,
389
+ "hash": details.hash,
390
+ "unity_version": details.unity_version,
391
+ "connected_at": details.connected_at,
392
+ })
393
+ return JSONResponse({"success": True, "instances": instances})
394
+ except Exception as e:
395
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
396
+
397
+ @mcp.custom_route("/plugin/sessions", methods=["GET"])
398
+ async def plugin_sessions_route(_: Request) -> JSONResponse:
399
+ data = await PluginHub.get_sessions()
400
+ return JSONResponse(data.model_dump())
401
+
402
+ # Initialize and register middleware for session-based Unity instance routing
403
+ # Using the singleton getter ensures we use the same instance everywhere
404
+ unity_middleware = get_unity_instance_middleware()
405
+ mcp.add_middleware(unity_middleware)
406
+ logger.info("Registered Unity instance middleware for session-based routing")
407
+
408
+ # Mount plugin websocket hub at /hub/plugin when HTTP transport is active
409
+ existing_routes = [
410
+ route for route in mcp._get_additional_http_routes()
411
+ if isinstance(route, WebSocketRoute) and route.path == "/hub/plugin"
412
+ ]
413
+ if not existing_routes:
414
+ mcp._additional_http_routes.append(
415
+ WebSocketRoute("/hub/plugin", PluginHub))
416
+
417
+ # Register all tools
418
+ register_all_tools(mcp, project_scoped_tools=project_scoped_tools)
419
+
420
+ # Register all resources
421
+ register_all_resources(mcp, project_scoped_tools=project_scoped_tools)
422
+
423
+ return mcp
325
424
 
326
425
 
327
426
  def main():
@@ -408,6 +507,11 @@ Examples:
408
507
  help="Optional path where the server will write its PID on startup. "
409
508
  "Used by Unity to stop the exact process it launched when running in a terminal."
410
509
  )
510
+ parser.add_argument(
511
+ "--project-scoped-tools",
512
+ action="store_true",
513
+ help="Keep custom tools scoped to the active Unity project and enable the custom tools resource."
514
+ )
411
515
 
412
516
  args = parser.parse_args()
413
517
 
@@ -429,8 +533,17 @@ Examples:
429
533
  # Allow individual host/port to override URL components
430
534
  http_host = args.http_host or os.environ.get(
431
535
  "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost"
432
- http_port = args.http_port or (int(os.environ.get("UNITY_MCP_HTTP_PORT")) if os.environ.get(
433
- "UNITY_MCP_HTTP_PORT") else None) or parsed_url.port or 8080
536
+
537
+ # Safely parse optional environment port (may be None or non-numeric)
538
+ _env_port_str = os.environ.get("UNITY_MCP_HTTP_PORT")
539
+ try:
540
+ _env_port = int(_env_port_str) if _env_port_str is not None else None
541
+ except ValueError:
542
+ logger.warning(
543
+ "Invalid UNITY_MCP_HTTP_PORT value '%s', ignoring", _env_port_str)
544
+ _env_port = None
545
+
546
+ http_port = args.http_port or _env_port or parsed_url.port or 8080
434
547
 
435
548
  os.environ["UNITY_MCP_HTTP_HOST"] = http_host
436
549
  os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
@@ -456,6 +569,8 @@ Examples:
456
569
  if args.http_port:
457
570
  logger.info(f"HTTP port override: {http_port}")
458
571
 
572
+ mcp = create_mcp_server(args.project_scoped_tools)
573
+
459
574
  # Determine transport mode
460
575
  if transport_mode == 'http':
461
576
  # Use HTTP transport for FastMCP
@@ -465,8 +580,7 @@ Examples:
465
580
  parsed_url = urlparse(http_url)
466
581
  host = args.http_host or os.environ.get(
467
582
  "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost"
468
- port = args.http_port or (int(os.environ.get("UNITY_MCP_HTTP_PORT")) if os.environ.get(
469
- "UNITY_MCP_HTTP_PORT") else None) or parsed_url.port or 8080
583
+ port = args.http_port or _env_port or parsed_url.port or 8080
470
584
  logger.info(f"Starting FastMCP with HTTP transport on {host}:{port}")
471
585
  mcp.run(transport=transport, host=host, port=port)
472
586
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 9.0.8
3
+ Version: 9.2.0
4
4
  Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
5
5
  Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
6
6
  License-Expression: MIT
@@ -32,6 +32,7 @@ Requires-Dist: pydantic>=2.12.5
32
32
  Requires-Dist: tomli>=2.3.0
33
33
  Requires-Dist: fastapi>=0.104.0
34
34
  Requires-Dist: uvicorn>=0.35.0
35
+ Requires-Dist: click>=8.1.0
35
36
  Provides-Extra: dev
36
37
  Requires-Dist: pytest>=8.0.0; extra == "dev"
37
38
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -109,7 +110,7 @@ Use this to run the latest released version from the repository. Change the vers
109
110
  "command": "uvx",
110
111
  "args": [
111
112
  "--from",
112
- "git+https://github.com/CoplayDev/unity-mcp@v9.0.8#subdirectory=Server",
113
+ "git+https://github.com/CoplayDev/unity-mcp@v9.2.0#subdirectory=Server",
113
114
  "mcp-for-unity",
114
115
  "--transport",
115
116
  "stdio"
@@ -1,20 +1,43 @@
1
- __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- main.py,sha256=2JiIIoWXnhEhEOg-YN-KWNG-tB_uGscR1U7dK_hQO88,19207
1
+ main.py,sha256=iscQv6VBCK-kHFDmu-Cgskc_S3-kWlF-2AuF-vMr0MI,23952
2
+ cli/__init__.py,sha256=f2HjXqR9d8Uhibru211t9HPpdrb_1vdDC2v_NwF_eqA,63
3
+ cli/main.py,sha256=LFrHFWxiUlX7Ttc-YxqHeXxIcEzhNndpvUJT_LQ28mw,7101
4
+ cli/commands/__init__.py,sha256=xQHf6o0afDV2HsU9gwSxjcrzS41cMCSGZyWYWxblPIk,69
5
+ cli/commands/animation.py,sha256=emBE5oKhFQNU8V2ENm9E5N4Grj0Tah9H0X7fF6grQdk,2442
6
+ cli/commands/asset.py,sha256=V1xzLgBPhdRzXsnj9Wt2HnJYo_8hT3RqoVnR2WrLP5w,7988
7
+ cli/commands/audio.py,sha256=qJ-Whc8aH7oUgT79O_RRRo-lAVktFqtC5pgbyG2bRNo,3333
8
+ cli/commands/batch.py,sha256=rMe8BDsthZ0AwaDrFoj6Kxl4xAVNRIlKSCcJ5eSagyY,5732
9
+ cli/commands/code.py,sha256=FGV8IDx6eFhcEmc6jREQwHwoOdiUyhY8d6Hy7KN4cTw,5624
10
+ cli/commands/component.py,sha256=uIOtno1T2mPF3rnW2OymetggScqtWrs_Th06FI7FISQ,6327
11
+ cli/commands/editor.py,sha256=mlfCQjw1PtA6V4ZY7HlbQ7ICDvvxu5EH7y6JOYMj0yU,13219
12
+ cli/commands/gameobject.py,sha256=b7ZxHXyIgUOvjYhHmKavigs-wfxGB6NhDMqqRyEGtNY,13643
13
+ cli/commands/instance.py,sha256=J6uQrNIEWbnJT-Y09ICTA9R11lgtPQflBbmTrBr5bg8,3041
14
+ cli/commands/lighting.py,sha256=eBvSDhQ5jkoUJJ4sito0yFxXwJv0JlpT4iD-D6Q2Pak,3869
15
+ cli/commands/material.py,sha256=51uxeoTgqnnMuUQUbhBTdMdI70kU4pOCH6GUIy2OjQI,7847
16
+ cli/commands/prefab.py,sha256=1t0fnGdDWD_a3yok02QI74YYmS1M92LDYfWvv_8iz90,3607
17
+ cli/commands/scene.py,sha256=P08rud-6FZaO8Tw9jnP0xcS043Bf5IAooGbEDZPVBqw,6274
18
+ cli/commands/script.py,sha256=Yf9o00irn4wf0cbsE665mxJehwtiIr0y3IHKLyvYhgY,6434
19
+ cli/commands/shader.py,sha256=CwIIgyrU9OosVmidD6E9Txmn6Yyo4rDJBubrBchAlVw,6380
20
+ cli/commands/ui.py,sha256=JDfAXE3ba45r41Svfop-fiy4p8C0gxE4ekJ8aFRG7aI,7627
21
+ cli/commands/vfx.py,sha256=5wKypI-QWa8Jd-Fp6bLGBiQw8wuIg6fynL6WiOJy36c,15199
22
+ cli/utils/__init__.py,sha256=Gbm9hYC7UqwloFwdirXgo6z1iBktR9Y96o3bQcrYudc,613
23
+ cli/utils/config.py,sha256=_k3XAFmXG22sv8tYIb5JmO46kNl3T1sGqFptySAayfc,1550
24
+ cli/utils/connection.py,sha256=T1xKA3Vr98Oj9b0RvFKQLAh2stvHwCdOiq3GIc58ZvE,6150
25
+ cli/utils/output.py,sha256=96daU55ta_hl7UeOhNh5Iy7OJ4psbdR9Nfx1-q2k3xA,6370
3
26
  core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
27
  core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
5
28
  core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
6
29
  core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
7
30
  core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
8
- mcpforunityserver-9.0.8.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
31
+ mcpforunityserver-9.2.0.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
9
32
  models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
10
33
  models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
11
34
  models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
12
35
  services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- services/custom_tool_service.py,sha256=VYqKcP0BZLTo9SGyNMtoLhFbzRlF8oCeMjeNdTScJiU,12320
36
+ services/custom_tool_service.py,sha256=WJxljL-hdJE5GMlAhVimHVhQwwnWHCd0StgWhWEFgaI,18592
14
37
  services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
15
38
  services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
16
39
  services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
17
- services/resources/__init__.py,sha256=O5heeMcgCswnQX1qG2nNtMeAZIaLut734qD7t5UsA0k,2801
40
+ services/resources/__init__.py,sha256=G8uSEYJtiyX3yg0QsfoeGdDXOdbU89l5m0B5Anay1Fc,3054
18
41
  services/resources/active_tool.py,sha256=zDuWRK1uz853TrMNv0w8vhZVOxemDPoI4QAkXSIezN8,1480
19
42
  services/resources/custom_tools.py,sha256=3t0mKAL9PkJbv8S4DpRFU8D-NlRWkCd2geO6QnlQo7I,1716
20
43
  services/resources/editor_state.py,sha256=pQdcsWGcKV7-6icpcVXtFD35CHUXodANc0jXkljVdLs,10823
@@ -29,7 +52,7 @@ services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2
29
52
  services/resources/unity_instances.py,sha256=XRR5YCDe8v_FXG45VlSdEPaqu7Qlbnm4NYIRzK5brjc,4354
30
53
  services/resources/windows.py,sha256=FyzPEtEmfKiXYh1lviemZ7-bFyjkAR61_seSTXQA9rk,1433
31
54
  services/state/external_changes_scanner.py,sha256=ZiXu8ZcK5B-hv7CaJLmnEIa9JxzgOBpdmrsRDY2eK5I,9052
32
- services/tools/__init__.py,sha256=3Qav7fAowZ1_TbDRdZQQQES53gv2lTs-2D7PGECnlbM,2353
55
+ services/tools/__init__.py,sha256=mS9EpbPWchYj6gNW1eu0REv-SLPsQkY8xTkk7u-DeMU,2607
33
56
  services/tools/batch_execute.py,sha256=hjh67kgWvQDHyGd2N-Tfezv9WAj5x_pWTt_Vybmmq7s,3501
34
57
  services/tools/debug_request_context.py,sha256=Duq5xiuSmRO5GdvWAlZhCfOfmrwvK7gGkRC4wYnXmXk,2907
35
58
  services/tools/execute_custom_tool.py,sha256=hiZbm2A9t84f92jitzvkE2G4CMOIUiDVm7u5B8K-RbU,1527
@@ -49,25 +72,25 @@ services/tools/manage_shader.py,sha256=bucRKzQww7opy6DK5nf6isVaEECWWqJ-DVkFulp8C
49
72
  services/tools/manage_vfx.py,sha256=eeqf4xUYw_yT2rALIGHrHLJCpemx9H__S3zCjj_GZsI,34054
50
73
  services/tools/preflight.py,sha256=0nvo0BmZMdIGop1Ha_vypkjn2VLiRvskF0uxh_SlZgE,4162
51
74
  services/tools/read_console.py,sha256=ps23debJcQkj3Ap-MqTYVhopYnKGspJs9QHLJHZAAkE,6826
52
- services/tools/refresh_unity.py,sha256=ksLcYkLK_es871MsyZSvE8XKUZBP09X2xaD8Qw949a8,4152
75
+ services/tools/refresh_unity.py,sha256=KrRA8bmLkDLFO1XBv2NmagQAp1dmyaXdUAap567Hcv4,7100
53
76
  services/tools/run_tests.py,sha256=wg8Ke8vpKHxyz0kqFaJC5feXTL3e6Cxzi0QKNitLDRE,9176
54
77
  services/tools/script_apply_edits.py,sha256=0f-SaP5NUYGuivl4CWHjR8F-CXUpt3-5qkHpf_edn1U,47677
55
78
  services/tools/set_active_instance.py,sha256=pdmC1SxFijyzzjeEyC2N1bXk-GNMu_iXsbCieIpa-R4,4242
56
79
  services/tools/utils.py,sha256=uk--6w_-O0eVAxczackXbgKde2ONmsgci43G3wY7dfA,4258
57
80
  transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
81
  transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
59
- transport/plugin_hub.py,sha256=ku8CdK6kdxUrRMd9zjUn7fFARx8rMZ2FWD7zWGuN2Ys,23083
82
+ transport/plugin_hub.py,sha256=X6tAnJU0s1LQtgIgiK_YHBhSWMRD5bRjbkGjOl8eLFQ,23725
60
83
  transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
61
84
  transport/unity_instance_middleware.py,sha256=DD8gs-peMRmRJz9CYwaHEh4m75LTYPDjVuKuw9sArBw,10438
62
85
  transport/unity_transport.py,sha256=G6aMC1qR31YZOBZs4fxQbSQBHuXBP1d5Qn0MJaB3yGs,3908
63
86
  transport/legacy/port_discovery.py,sha256=JDSCqXLodfTT7fOsE0DFC1jJ3QsU6hVaYQb7x7FgdxY,12728
64
87
  transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
65
- transport/legacy/unity_connection.py,sha256=UZ6tztBO9VlXNiV0jN66k5QMrtTIGAOdGxwtcLnkXLU,35808
88
+ transport/legacy/unity_connection.py,sha256=FE9ZQfYMhHvIxBycr_DjI3BKvuEdORXuABnCE5Q2tjQ,36733
66
89
  utils/focus_nudge.py,sha256=HaTOSI7wzDmdRviodUHx2oQFPIL_jSwubai3YkDJbH0,9910
67
90
  utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
68
91
  utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
69
- mcpforunityserver-9.0.8.dist-info/METADATA,sha256=pcOGsAevkWxVjioSfj9QIyVQulKvlfpctKI8T8-8fww,5761
70
- mcpforunityserver-9.0.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
71
- mcpforunityserver-9.0.8.dist-info/entry_points.txt,sha256=vCtkqw-J9t4pj7JwUZcB54_keVx7DpAR3fYzK6i-s6g,44
72
- mcpforunityserver-9.0.8.dist-info/top_level.txt,sha256=UYGWDnyTlnS7PnuZNw8-gM_jWcdmcHwffK_2yBRl6Cc,51
73
- mcpforunityserver-9.0.8.dist-info/RECORD,,
92
+ mcpforunityserver-9.2.0.dist-info/METADATA,sha256=Nu07P-u2PL3YJZnlJMWNmmaAWtyuhF_EX3Ag4NogEBc,5789
93
+ mcpforunityserver-9.2.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
94
+ mcpforunityserver-9.2.0.dist-info/entry_points.txt,sha256=pPm70RXQvkt3uBhPOtViDa47ZTA03RaQ6rwXvyi8oiI,70
95
+ mcpforunityserver-9.2.0.dist-info/top_level.txt,sha256=3-A65WsmBO6UZYH8O5mINdyhhZ63SDssr8LncRd1PSQ,46
96
+ mcpforunityserver-9.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  mcp-for-unity = main:main
3
+ unity-mcp = cli.main:main
@@ -1,4 +1,4 @@
1
- __init__
1
+ cli
2
2
  core
3
3
  main
4
4
  models
@@ -1,21 +1,26 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging
3
4
  import time
4
5
  from hashlib import sha256
5
6
  from typing import Optional
6
7
 
7
- from fastmcp import FastMCP
8
+ from fastmcp import Context, FastMCP
8
9
  from pydantic import BaseModel, Field, ValidationError
9
10
  from starlette.requests import Request
10
11
  from starlette.responses import JSONResponse
11
12
 
12
13
  from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
14
+ from core.logging_decorator import log_execution
15
+ from core.telemetry_decorator import telemetry_tool
13
16
  from transport.unity_transport import send_with_unity_instance
14
17
  from transport.legacy.unity_connection import (
15
18
  async_send_command_with_retry,
16
19
  get_unity_connection_pool,
17
20
  )
18
21
  from transport.plugin_hub import PluginHub
22
+ from services.tools import get_unity_instance_from_context
23
+ from services.registry import get_registered_tools
19
24
 
20
25
  logger = logging.getLogger("mcp-for-unity-server")
21
26
 
@@ -39,11 +44,13 @@ class ToolRegistrationResponse(BaseModel):
39
44
  class CustomToolService:
40
45
  _instance: "CustomToolService | None" = None
41
46
 
42
- def __init__(self, mcp: FastMCP):
47
+ def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
43
48
  CustomToolService._instance = self
44
49
  self._mcp = mcp
50
+ self._project_scoped_tools = project_scoped_tools
45
51
  self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
46
52
  self._hash_to_project: dict[str, str] = {}
53
+ self._global_tools: dict[str, ToolDefinitionModel] = {}
47
54
  self._register_http_routes()
48
55
 
49
56
  @classmethod
@@ -61,17 +68,8 @@ class CustomToolService:
61
68
  except ValidationError as exc:
62
69
  return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
63
70
 
64
- registered: list[str] = []
65
- replaced: list[str] = []
66
- for tool in payload.tools:
67
- if self._is_registered(payload.project_id, tool.name):
68
- replaced.append(tool.name)
69
- self._register_tool(payload.project_id, tool)
70
- registered.append(tool.name)
71
-
72
- if payload.project_hash:
73
- self._hash_to_project[payload.project_hash.lower(
74
- )] = payload.project_id
71
+ registered, replaced = self._register_project_tools(
72
+ payload.project_id, payload.tools, project_hash=payload.project_hash)
75
73
 
76
74
  message = f"Registered {len(registered)} tool(s)"
77
75
  if replaced:
@@ -266,6 +264,163 @@ class CustomToolService:
266
264
  return None
267
265
  return {"message": str(response)}
268
266
 
267
+ def _register_project_tools(
268
+ self,
269
+ project_id: str,
270
+ tools: list[ToolDefinitionModel],
271
+ project_hash: str | None = None,
272
+ ) -> tuple[list[str], list[str]]:
273
+ registered: list[str] = []
274
+ replaced: list[str] = []
275
+ for tool in tools:
276
+ if self._is_registered(project_id, tool.name):
277
+ replaced.append(tool.name)
278
+ self._register_tool(project_id, tool)
279
+ registered.append(tool.name)
280
+ if not self._project_scoped_tools:
281
+ self._register_global_tool(tool)
282
+
283
+ if project_hash:
284
+ self._hash_to_project[project_hash.lower()] = project_id
285
+
286
+ return registered, replaced
287
+
288
+ def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None:
289
+ if self._project_scoped_tools:
290
+ return
291
+ builtin_names = self._get_builtin_tool_names()
292
+ for tool in tools:
293
+ if tool.name in builtin_names:
294
+ logger.info(
295
+ "Skipping global custom tool registration for built-in tool '%s'",
296
+ tool.name,
297
+ )
298
+ continue
299
+ self._register_global_tool(tool)
300
+
301
+ def _get_builtin_tool_names(self) -> set[str]:
302
+ return {tool["name"] for tool in get_registered_tools()}
303
+
304
+ def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
305
+ existing = self._global_tools.get(definition.name)
306
+ if existing:
307
+ if existing.model_dump() != definition.model_dump():
308
+ logger.warning(
309
+ "Custom tool '%s' already registered with a different schema; keeping existing definition.",
310
+ definition.name,
311
+ )
312
+ return
313
+
314
+ handler = self._build_global_tool_handler(definition)
315
+ wrapped = log_execution(definition.name, "Tool")(handler)
316
+ wrapped = telemetry_tool(definition.name)(wrapped)
317
+
318
+ try:
319
+ wrapped = self._mcp.tool(
320
+ name=definition.name,
321
+ description=definition.description,
322
+ )(wrapped)
323
+ except Exception as exc: # pragma: no cover - defensive against tool conflicts
324
+ logger.warning(
325
+ "Failed to register custom tool '%s' globally: %s",
326
+ definition.name,
327
+ exc,
328
+ )
329
+ return
330
+
331
+ self._global_tools[definition.name] = definition
332
+
333
+ def _build_global_tool_handler(self, definition: ToolDefinitionModel):
334
+ async def _handler(ctx: Context, **kwargs) -> MCPResponse:
335
+ unity_instance = get_unity_instance_from_context(ctx)
336
+ if not unity_instance:
337
+ return MCPResponse(
338
+ success=False,
339
+ message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
340
+ )
341
+
342
+ project_id = resolve_project_id_for_unity_instance(unity_instance)
343
+ if project_id is None:
344
+ return MCPResponse(
345
+ success=False,
346
+ message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
347
+ )
348
+
349
+ params = {k: v for k, v in kwargs.items() if v is not None}
350
+ service = CustomToolService.get_instance()
351
+ return await service.execute_tool(project_id, definition.name, unity_instance, params)
352
+
353
+ _handler.__name__ = f"custom_tool_{definition.name}"
354
+ _handler.__doc__ = definition.description or ""
355
+ _handler.__signature__ = self._build_signature(definition)
356
+ _handler.__annotations__ = self._build_annotations(definition)
357
+ return _handler
358
+
359
+ def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature:
360
+ params: list[inspect.Parameter] = [
361
+ inspect.Parameter(
362
+ "ctx",
363
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
364
+ annotation=Context,
365
+ )
366
+ ]
367
+ for param in definition.parameters:
368
+ if not param.name.isidentifier():
369
+ logger.warning(
370
+ "Custom tool '%s' has non-identifier parameter '%s'; exposing via kwargs only.",
371
+ definition.name,
372
+ param.name,
373
+ )
374
+ continue
375
+ default = inspect._empty if param.required else self._coerce_default(
376
+ param.default_value, param.type)
377
+ params.append(
378
+ inspect.Parameter(
379
+ param.name,
380
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
381
+ default=default,
382
+ annotation=self._map_param_type(param),
383
+ )
384
+ )
385
+ return inspect.Signature(parameters=params)
386
+
387
+ def _build_annotations(self, definition: ToolDefinitionModel) -> dict[str, object]:
388
+ annotations: dict[str, object] = {"ctx": Context}
389
+ for param in definition.parameters:
390
+ if not param.name.isidentifier():
391
+ continue
392
+ annotations[param.name] = self._map_param_type(param)
393
+ return annotations
394
+
395
+ def _map_param_type(self, param: ToolParameterModel):
396
+ ptype = (param.type or "string").lower()
397
+ if ptype in ("integer", "int"):
398
+ return int
399
+ if ptype in ("number", "float", "double"):
400
+ return float
401
+ if ptype in ("bool", "boolean"):
402
+ return bool
403
+ if ptype in ("array", "list"):
404
+ return list
405
+ if ptype in ("object", "dict"):
406
+ return dict
407
+ return str
408
+
409
+ def _coerce_default(self, value: str | None, param_type: str | None):
410
+ if value is None:
411
+ return None
412
+ try:
413
+ ptype = (param_type or "string").lower()
414
+ if ptype in ("integer", "int"):
415
+ return int(value)
416
+ if ptype in ("number", "float", "double"):
417
+ return float(value)
418
+ if ptype in ("bool", "boolean"):
419
+ return str(value).lower() in ("1", "true", "yes", "on")
420
+ return value
421
+ except Exception:
422
+ return value
423
+
269
424
 
270
425
  def compute_project_id(project_name: str, project_path: str) -> str:
271
426
  """