Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 335 additions & 0 deletions MCPForUnity/Editor/Helpers/GameViewSizeControl.cs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions MCPForUnity/Editor/Helpers/GameViewSizeControl.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion MCPForUnity/Editor/Tools/ManageEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,20 @@ public static object HandleCommand(JObject @params)
}
string action = actionResult.Value.ToLowerInvariant();

if (action != "set_game_view_size" && (p.Has("width") || p.Has("height")))
return new ErrorResponse("width and height are only valid for set_game_view_size.");

// Parameters for specific actions
string tagName = p.Get("tagName");
string layerName = p.Get("layerName");
// Route action
switch (action)
{
case "get_game_view_size":
return GameViewSizeControl.Get();
case "set_game_view_size":
return GameViewSizeControl.Set(p);

// Play Mode Control
case "play":
try
Expand Down Expand Up @@ -171,7 +179,7 @@ public static object HandleCommand(JObject @params)

default:
return new ErrorResponse(
$"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
$"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo, get_game_view_size, set_game_view_size. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
);
}
}
Expand Down
29 changes: 29 additions & 0 deletions Server/src/cli/commands/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,35 @@ def stop():
print_success("Stopped play mode")


@editor.command("game-view-size")
@click.option("--width", type=click.IntRange(10, 8192), default=None,
help="Fixed width in pixels. Supply with --height to set the size.")
@click.option("--height", type=click.IntRange(10, 8192), default=None,
help="Fixed height in pixels. Supply with --width to set the size.")
@handle_unity_errors
def game_view_size(width: Optional[int], height: Optional[int]):
"""Read or set the open Game View's fixed render resolution.

Requires a focused Game View, or exactly one open Game View. Make it visible
before setting dimensions so Unity can repaint and verify its render target. Reuses one
MCP-owned preset per platform group in shared Editor preferences; other
presets and focus stay intact. Actual size can be limited by the GPU.

\b
Examples:
unity-mcp editor game-view-size
unity-mcp editor game-view-size --width 2400 --height 1080
"""
if (width is None) != (height is None):
raise click.UsageError("Supply both --width and --height, or neither to read the size.")
config = get_config()
params: dict[str, Any] = {"action": "get_game_view_size"}
if width is not None:
params.update(action="set_game_view_size", width=width, height=height)
result = run_command("manage_editor", params, config)
click.echo(format_output(result, config.format))


@editor.command("console")
@click.option(
"--type", "-t",
Expand Down
18 changes: 16 additions & 2 deletions Server/src/services/tools/manage_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from fastmcp import Context
from mcp.types import ToolAnnotations
from pydantic import Field

from services.registry import mcp_for_unity_tool
from core.telemetry import is_telemetry_enabled, record_tool_usage
Expand All @@ -10,7 +11,7 @@
from transport.legacy.unity_connection import async_send_command_with_retry

@mcp_for_unity_tool(
description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.",
description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping, get_game_view_size. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo, set_game_view_size. Game View size actions require a graphical Editor and an open Game View (focused, or the only open Game View). Make it visible before setting its size so Unity can repaint. set_game_view_size requires integer width and height (10..8192); it reuses one MCP-owned preset per platform group in shared Editor preferences and waits for render-target size readback. It preserves other presets and does not change focus. Read requested_size, target_size, render_size and settled separately: GPU limits can reduce the actual size; a timeout can leave the new preset selected. For prefab editing (open/save/close prefab stage), use manage_prefabs. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.",
annotations=ToolAnnotations(
title="Manage Editor",
readOnlyHint=False,
Expand All @@ -19,13 +20,17 @@
)
async def manage_editor(
ctx: Context,
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs."],
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package", "undo", "redo", "get_game_view_size", "set_game_view_size"], "Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs."],
tool_name: Annotated[str,
"Tool name when setting active tool"] | None = None,
tag_name: Annotated[str,
"Tag name when adding and removing tags"] | None = None,
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
width: Annotated[int, Field(strict=True, ge=10, le=8192),
"Fixed Game View width in pixels (10..8192), required for set_game_view_size"] | None = None,
height: Annotated[int, Field(strict=True, ge=10, le=8192),
"Fixed Game View height in pixels (10..8192), required for set_game_view_size"] | None = None,
) -> dict[str, Any]:
# Get active instance from request state (injected by middleware)
unity_instance = await get_unity_instance_from_context(ctx)
Expand All @@ -39,12 +44,21 @@ async def manage_editor(
record_tool_usage("diagnostic_ping", True, 1.0, None)
return {"success": True, "message": "telemetry ping queued"}

if action == "set_game_view_size":
for name, value in (("width", width), ("height", height)):
if type(value) is not int or not 10 <= value <= 8192:
return {"success": False, "message": f"{name} must be an integer between 10 and 8192."}
elif width is not None or height is not None:
return {"success": False, "message": "width and height are only valid for set_game_view_size."}

# Prepare parameters, removing None values
params = {
"action": action,
"toolName": tool_name,
"tagName": tag_name,
"layerName": layer_name,
"width": width,
"height": height,
}
params = {k: v for k, v in params.items() if v is not None}

Expand Down
28 changes: 28 additions & 0 deletions Server/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1596,3 +1596,31 @@ def test_texture_sprite_color_and_pattern_precedence(self, runner, mock_unity_re

if __name__ == "__main__":
pytest.main([__file__, "-v"])


class TestGameViewSizeCommand:
def test_get_size(self, runner, mock_unity_response):
with patch("cli.commands.editor.run_command", return_value=mock_unity_response) as run:
result = runner.invoke(cli, ["editor", "game-view-size"])
assert result.exit_code == 0, result.output
assert run.call_args.args[:2] == ("manage_editor", {"action": "get_game_view_size"})

@pytest.mark.parametrize("width,height", [(2400, 1080), (1080, 2400), (10, 8192)])
def test_set_size(self, runner, mock_unity_response, width, height):
with patch("cli.commands.editor.run_command", return_value=mock_unity_response) as run:
result = runner.invoke(cli, ["editor", "game-view-size", "--width", str(width), "--height", str(height)])
assert result.exit_code == 0, result.output
assert run.call_args.args[:2] == ("manage_editor", {"action": "set_game_view_size", "width": width, "height": height})

@pytest.mark.parametrize("options", [
["--width", "2400"], ["--height", "1080"],
["--width", "9", "--height", "1080"],
["--width", "2400", "--height", "8193"],
["--width", "1080.5", "--height", "1080"],
["--width", "true", "--height", "1080"],
])
def test_invalid_size_does_not_contact_unity(self, runner, options):
with patch("cli.commands.editor.run_command") as run:
result = runner.invoke(cli, ["editor", "game-view-size", *options])
assert result.exit_code != 0
run.assert_not_called()
55 changes: 55 additions & 0 deletions Server/tests/test_manage_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,58 @@ def test_undo_omits_none_params(mock_unity):
assert "layerName" not in params




@pytest.mark.parametrize("width,height", [(2400, 1080), (1080, 2400), (10, 10), (8192, 8192)])
def test_set_game_view_size_forwards_dimensions_and_instance(mock_unity, width, height):
result = asyncio.run(manage_editor(SimpleNamespace(), action="set_game_view_size", width=width, height=height))
assert result["success"] is True
assert mock_unity["unity_instance"] == "unity-instance-1"
assert mock_unity["params"] == {"action": "set_game_view_size", "width": width, "height": height}


@pytest.mark.parametrize("invalid", [None, 0, -1, 9, 8193, 2**63, True, 1080.5, "1080"])
@pytest.mark.parametrize("dimension", ["width", "height"])
def test_set_game_view_size_rejects_invalid_before_transport(mock_unity, dimension, invalid):
dimensions = {"width": 2400, "height": 1080, dimension: invalid}
result = asyncio.run(manage_editor(SimpleNamespace(), action="set_game_view_size", **dimensions))
assert result["success"] is False
assert dimension in result["message"]
assert "params" not in mock_unity


def test_get_game_view_size_forwards_without_dimensions(mock_unity):
result = asyncio.run(manage_editor(SimpleNamespace(), action="get_game_view_size"))
assert result["success"] is True
assert mock_unity["params"] == {"action": "get_game_view_size"}


@pytest.mark.parametrize("action", ["get_game_view_size", "play"])
def test_dimensions_are_not_silently_ignored(mock_unity, action):
result = asyncio.run(manage_editor(SimpleNamespace(), action=action, width=2400, height=1080))
assert result["success"] is False
assert "params" not in mock_unity


@pytest.mark.parametrize("response", [
{"success": True, "data": {"requested_size": {"width": 2400, "height": 1080}, "render_size": {"width": 1200, "height": 540}, "matches_requested": False}},
{"success": False, "code": "game_view_size_readback_timeout", "data": {"preset_may_have_changed": True, "settled": False}},
])
def test_size_readback_and_partial_failure_are_preserved(monkeypatch, mock_unity, response):
monkeypatch.setattr(manage_editor_mod, "send_with_unity_instance", AsyncMock(return_value=response))
result = asyncio.run(manage_editor(SimpleNamespace(), action="set_game_view_size", width=2400, height=1080))
assert result["success"] == response["success"]
assert result["data"] == response["data"]
if not response["success"]:
assert result["code"] == response["code"]


def test_dimension_schema_rejects_coercion():
from typing import get_type_hints
from pydantic import TypeAdapter, ValidationError

adapter = TypeAdapter(get_type_hints(manage_editor, include_extras=True)["width"])
for invalid in [True, 1080.0, "1080", 9, 8193]:
with pytest.raises(ValidationError):
adapter.validate_python(invalid)
assert adapter.validate_python(2400) == 2400
Loading