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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +87 -0
- cli/commands/asset.py +310 -0
- cli/commands/audio.py +133 -0
- cli/commands/batch.py +184 -0
- cli/commands/code.py +189 -0
- cli/commands/component.py +212 -0
- cli/commands/editor.py +487 -0
- cli/commands/gameobject.py +510 -0
- cli/commands/instance.py +101 -0
- cli/commands/lighting.py +128 -0
- cli/commands/material.py +268 -0
- cli/commands/prefab.py +144 -0
- cli/commands/scene.py +255 -0
- cli/commands/script.py +240 -0
- cli/commands/shader.py +238 -0
- cli/commands/ui.py +263 -0
- cli/commands/vfx.py +439 -0
- cli/main.py +248 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/connection.py +191 -0
- cli/utils/output.py +195 -0
- main.py +174 -60
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/METADATA +3 -2
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/RECORD +37 -14
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/top_level.txt +1 -1
- services/custom_tool_service.py +168 -13
- services/resources/__init__.py +6 -1
- services/tools/__init__.py +6 -1
- services/tools/refresh_unity.py +66 -16
- transport/legacy/unity_connection.py +26 -8
- transport/plugin_hub.py +17 -0
- __init__.py +0 -0
- {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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
#
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
70
|
-
mcpforunityserver-9.0.
|
|
71
|
-
mcpforunityserver-9.0.
|
|
72
|
-
mcpforunityserver-9.0.
|
|
73
|
-
mcpforunityserver-9.0.
|
|
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,,
|
services/custom_tool_service.py
CHANGED
|
@@ -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
|
|
65
|
-
|
|
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
|
"""
|