Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b7929ed
feat(stdio): add MCP tool toggle support for stdio transport
whatevertogo Feb 16, 2026
b88905f
feat(stdio): add tools/list_changed notifications for tool toggle
whatevertogo Feb 16, 2026
640aef8
feat(stdio): add configuration options for stdio status freshness and…
whatevertogo Feb 16, 2026
b87baaf
Add per-call unity_instance routing via middleware argument interception
dsarno Feb 18, 2026
2e2ca8a
merge: PR #772 per-call unity_instance routing with stdio tool toggle
whatevertogo Feb 18, 2026
4e8b556
Merge branch 'CoplayDev:beta' into feat/stdio-tool-toggle-from-dev
whatevertogo Feb 18, 2026
252a540
Merge branch 'feat/stdio-tool-toggle-from-dev' of github.com:whatever…
whatevertogo Feb 18, 2026
7036b45
feat: add stdio tool filtering tests and address code review feedback
whatevertogo Feb 18, 2026
5b475a1
test(middleware): add status file schema compatibility tests
whatevertogo Feb 18, 2026
01920d2
feat(editor): add HTTP tool reregistration on tool toggle
whatevertogo Feb 18, 2026
8fe196a
Refactor instance resolution and simplify status refresh handling
whatevertogo Feb 19, 2026
aaa3260
Merge pull request #12 from whatevertogo/codex/simplify-duplicated-br…
whatevertogo Feb 19, 2026
5d9b4fd
Merge branch 'feat/stdio-tool-toggle-from-dev' of github.com:whatever…
whatevertogo Feb 19, 2026
5feeb33
fix: harden stdio tool refresh and instance selection middleware
whatevertogo Feb 19, 2026
683a6f5
test: update set_active_instance integration mocks for middleware res…
whatevertogo Feb 19, 2026
345a36a
merge: resolve PR #763 conflicts with upstream/beta
whatevertogo Feb 19, 2026
fa5d4f5
Merge branch 'CoplayDev:beta' into feat/stdio-tool-toggle-from-dev
whatevertogo Feb 20, 2026
b52dcad
fix: streamline tool disabling logic and enhance tool visibility checks
whatevertogo Feb 20, 2026
e22594e
Merge branch 'feat/stdio-tool-toggle-from-dev' of github.com:whatever…
whatevertogo Feb 20, 2026
efff0df
Merge branch 'CoplayDev:beta' into feat/stdio-tool-toggle-from-dev
whatevertogo Feb 23, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,19 @@ private static bool IsValidJson(string text)
return false;
}

public static void RefreshStatusFile(string reason = "manual_refresh")
{
try
{
heartbeatSeq++;
WriteHeartbeat(false, reason);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to refresh stdio status file: {ex.Message}");
}
}


public static void WriteHeartbeat(bool reloading, string reason = null)
{
Expand All @@ -1025,7 +1038,8 @@ public static void WriteHeartbeat(bool reloading, string reason = null)
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
string projectHash = ComputeProjectHash(Application.dataPath);
string filePath = Path.Combine(dir, $"unity-mcp-status-{projectHash}.json");

string projectName = "Unknown";
try
Expand All @@ -1047,14 +1061,33 @@ public static void WriteHeartbeat(bool reloading, string reason = null)
}
catch { }

string[] enabledTools = Array.Empty<string>();
try
{
var toolMetadata = MCPServiceLocator.ToolDiscovery.GetEnabledTools();
enabledTools = toolMetadata
?.Select(tool => tool?.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray()
?? Array.Empty<string>();
Comment on lines +1064 to +1074
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enabled_tools array is built by filtering GetEnabledTools() which only returns enabled tools. This means when all Unity-managed tools are disabled, enabled_tools will be an empty array. However, an empty array is semantically different from a missing field - it explicitly states "no tools are enabled" rather than "state unknown".

The Python middleware correctly handles this case by checking for an empty set and filtering out all Unity-managed tools while keeping server-only tools visible. However, this semantic difference could be documented more clearly - an empty array means "all Unity tools disabled" while a missing field means "backward compatibility: no filtering".

Copilot uses AI. Check for mistakes.
}
catch (Exception ex)
{
McpLog.Warn($"Failed to resolve enabled tools for stdio status file: {ex.Message}");
}

var payload = new
{
unity_port = currentUnityPort,
reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
project_hash = projectHash,
project_path = Application.dataPath,
project_name = projectName,
enabled_tools = enabledTools,
unity_version = Application.unityVersion,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
Expand Down
161 changes: 160 additions & 1 deletion MCPForUnity/Editor/Tools/ManageEditor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal; // Required for tag management
Expand Down Expand Up @@ -101,6 +105,38 @@ public static object HandleCommand(JObject @params)
if (!toolNameResult.IsSuccess)
return new ErrorResponse(toolNameResult.ErrorMessage);
return SetActiveTool(toolNameResult.Value);
case "set_mcp_tool_enabled":
var setToolEnabledNameResult = p.GetRequired(
"toolName",
"'toolName' parameter required for set_mcp_tool_enabled.");
if (!setToolEnabledNameResult.IsSuccess)
{
return new ErrorResponse(setToolEnabledNameResult.ErrorMessage);
}

if (!p.Has("enabled"))
{
return new ErrorResponse("'enabled' parameter required for set_mcp_tool_enabled.");
}

bool? enabled = ParamCoercion.CoerceBoolNullable(p.GetRaw("enabled"));
if (!enabled.HasValue)
{
return new ErrorResponse("'enabled' parameter must be a boolean.");
}

return SetMcpToolEnabled(setToolEnabledNameResult.Value, enabled.Value);
case "get_mcp_tool_enabled":
var getToolEnabledNameResult = p.GetRequired(
"toolName",
"'toolName' parameter required for get_mcp_tool_enabled.");
if (!getToolEnabledNameResult.IsSuccess)
{
return new ErrorResponse(getToolEnabledNameResult.ErrorMessage);
}
return GetMcpToolEnabled(getToolEnabledNameResult.Value);
case "list_mcp_tools":
return ListMcpTools();

// Tag Management
case "add_tag":
Expand Down Expand Up @@ -136,7 +172,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. 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, set_mcp_tool_enabled, get_mcp_tool_enabled, list_mcp_tools, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool."
);
}
}
Expand Down Expand Up @@ -178,6 +214,129 @@ private static object SetActiveTool(string toolName)
}
}

private static object SetMcpToolEnabled(string toolName, bool enabled)
{
if (string.IsNullOrWhiteSpace(toolName))
{
return new ErrorResponse("Tool name cannot be empty.");
}

var metadata = MCPServiceLocator.ToolDiscovery.GetToolMetadata(toolName);
if (metadata == null)
{
return new ErrorResponse($"Unknown tool '{toolName}'.");
}

if (!enabled && string.Equals(metadata.Name, "manage_editor", StringComparison.OrdinalIgnoreCase))
{
return new ErrorResponse($"Tool '{metadata.Name}' cannot be disabled.");
}

MCPServiceLocator.ToolDiscovery.SetToolEnabled(metadata.Name, enabled);
RefreshStdioStatusFile();
RefreshHttpToolRegistration();

return new SuccessResponse(
$"Tool '{metadata.Name}' {(enabled ? "enabled" : "disabled")} successfully.",
new
{
toolName = metadata.Name,
enabled
});
}

private static object GetMcpToolEnabled(string toolName)
{
if (string.IsNullOrWhiteSpace(toolName))
{
return new ErrorResponse("Tool name cannot be empty.");
}

var metadata = MCPServiceLocator.ToolDiscovery.GetToolMetadata(toolName);
if (metadata == null)
{
return new ErrorResponse($"Unknown tool '{toolName}'.");
}

bool enabled = MCPServiceLocator.ToolDiscovery.IsToolEnabled(metadata.Name);
return new SuccessResponse(
$"Tool '{metadata.Name}' is {(enabled ? "enabled" : "disabled")}.",
new
{
toolName = metadata.Name,
enabled
});
}

private static object ListMcpTools()
{
try
{
var discoveredTools = MCPServiceLocator.ToolDiscovery.DiscoverAllTools();
var toolStates = new JArray();

foreach (var tool in discoveredTools)
{
toolStates.Add(new JObject
{
["name"] = tool.Name,
["enabled"] = MCPServiceLocator.ToolDiscovery.IsToolEnabled(tool.Name),
["autoRegister"] = tool.AutoRegister,
["isBuiltIn"] = tool.IsBuiltIn
});
}

return new SuccessResponse(
$"Listed {toolStates.Count} MCP tools.",
new JObject
{
["toolCount"] = toolStates.Count,
["tools"] = toolStates
});
}
catch (Exception e)
{
return new ErrorResponse($"Failed to list MCP tools: {e.Message}");
}
}

private static void RefreshStdioStatusFile()
{
if (!StdioBridgeHost.IsRunning)
return;

StdioBridgeHost.RefreshStatusFile("tool_toggle");
}

private static void RefreshHttpToolRegistration()
{
try
{
var transportManager = MCPServiceLocator.TransportManager;
var client = transportManager.GetClient(TransportMode.Http);
if (client == null || !client.IsConnected)
{
return;
}

_ = Task.Run(async () =>
{
try
{
await client.ReregisterToolsAsync().ConfigureAwait(false);
}
catch (Exception e)
{
McpLog.Warn($"Failed to reregister HTTP tools after tool toggle: {e.Message}");
}
});
}
catch (Exception e)
{
McpLog.Warn($"Failed to schedule HTTP tool reregistration after tool toggle: {e.Message}");
}
}

// --- Tag Management Methods ---

private static object AddTag(string tagName)
Expand Down
41 changes: 41 additions & 0 deletions Server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ These options apply to the `mcp-for-unity` command (whether run via `uvx`, Docke
- `UNITY_MCP_HTTP_REMOTE_HOSTED` - Enable remote-hosted mode (`true`, `1`, or `yes`)
- `UNITY_MCP_DEFAULT_INSTANCE` - Default Unity instance to target (project name, hash, or `Name@hash`)
- `UNITY_MCP_SKIP_STARTUP_CONNECT=1` - Skip initial Unity connection attempt on startup
- `UNITY_MCP_STDIO_STATUS_TTL_SECONDS` - Freshness window for stdio status files used by tools/list filtering (default: `15`)
- `UNITY_MCP_STDIO_TOOLS_WATCH_INTERVAL_SECONDS` - Poll interval for stdio tool-list change watcher in seconds (default: `1.0`, minimum: `0.2`)

API key authentication (remote-hosted mode):

Expand All @@ -164,6 +166,45 @@ Telemetry:
- `UNITY_MCP_TELEMETRY_ENDPOINT` - Override telemetry endpoint URL
- `UNITY_MCP_TELEMETRY_TIMEOUT` - Override telemetry request timeout (seconds)

### MCP tool toggles in stdio

The `manage_editor` tool exposes MCP tool enable/disable controls:

- `set_mcp_tool_enabled` (`tool_name`, `enabled`)
- `get_mcp_tool_enabled` (`tool_name`)
- `list_mcp_tools`

Example:

```json
{
"action": "set_mcp_tool_enabled",
"tool_name": "manage_scene",
"enabled": false
}
```

When running in `stdio`, `tools/list` is filtered by Unity's enabled tool state.
If all Unity-managed tools are disabled, `tools/list` will only show server-only tools.
The Unity status file (`~/.unity-mcp/unity-mcp-status-<hash>.json`) now includes:

- `project_hash`
- `enabled_tools`

Tool toggle changes trigger an immediate status-file refresh, so `tools/list`
updates do not depend on waiting for the next heartbeat.
When a client session initializes in `stdio`, the server sends
`notifications/tools/list_changed` to trigger an immediate tool-list refresh.
During runtime, a stdio watcher monitors status-file changes and emits the same
notification when enabled-tool state changes.
To avoid stale instance data, stdio filtering only uses recent status files
(default freshness window: 15s, configurable via `UNITY_MCP_STDIO_STATUS_TTL_SECONDS`).
Watcher interval defaults to 1.0s (minimum 0.2s), configurable via
`UNITY_MCP_STDIO_TOOLS_WATCH_INTERVAL_SECONDS`.
Compatibility note: if a client ignores `notifications/tools/list_changed`,
tool calls still enforce enabled/disabled state, but visible list updates may
still require reconnecting that client.

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation describes the new tool toggle functionality but doesn't document the new environment variables mentioned in the PR description:

  • UNITY_MCP_STDIO_TOOLS_WATCH_INTERVAL_SECONDS (default: 1.0, min: 0.2)
  • UNITY_MCP_STDIO_STATUS_TTL_SECONDS (default: 15.0)

These should be added to the environment variables section of the README to maintain consistency with other configuration options.

Suggested change
#### Tool toggle environment variables
These environment variables control how often the server checks for tool/status updates
and how long status information is considered valid:
- `UNITY_MCP_STDIO_TOOLS_WATCH_INTERVAL_SECONDS`
Interval in seconds between checks for tool/status file changes.
Default: `1.0` (minimum: `0.2`).
- `UNITY_MCP_STDIO_STATUS_TTL_SECONDS`
Time-to-live in seconds for status information before it is considered stale.
Default: `15.0`.

Copilot uses AI. Check for mistakes.
### Examples

**Stdio (default):**
Expand Down
10 changes: 10 additions & 0 deletions Server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
global _unity_connection_pool, _server_version
_server_version = get_package_version()
logger.info(f"MCP for Unity Server v{_server_version} starting up")
unity_middleware = get_unity_instance_middleware()

# Register custom tool management endpoints with FastMCP
# Routes are declared globally below after FastMCP initialization
Expand All @@ -164,6 +165,11 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
loop = asyncio.get_running_loop()
PluginHub.configure(_plugin_registry, loop)

try:
await unity_middleware.start_stdio_tools_watcher()
except Exception:
logger.debug("Failed to start stdio tools watcher.", exc_info=True)

# Record server startup telemetry
start_time = time.time()
start_clk = time.perf_counter()
Expand Down Expand Up @@ -248,6 +254,10 @@ def _emit_startup():
"plugin_registry": _plugin_registry,
}
finally:
try:
await unity_middleware.stop_stdio_tools_watcher()
except Exception:
logger.debug("Failed to stop stdio tools watcher.", exc_info=True)
if _unity_connection_pool:
_unity_connection_pool.disconnect_all()
logger.info("MCP for Unity Server shut down")
Expand Down
Loading