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
5 changes: 5 additions & 0 deletions MCPForUnity/Editor/Services/IServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public interface IServerManagementService
/// </summary>
bool StopLocalHttpServer();

/// <summary>
/// Requests cooperative shutdown before falling back to guarded process termination.
/// </summary>
System.Threading.Tasks.Task<bool> StopLocalHttpServerAsync();

/// <summary>
/// Stop the Unity-managed local HTTP server if a handshake/pidfile exists,
/// even if the current transport selection has changed.
Expand Down
2 changes: 1 addition & 1 deletion MCPForUnity/Editor/Services/Server/IProcessDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public interface IProcessDetector
int GetCurrentProcessId();

/// <summary>
/// Checks if a process exists on Unix systems.
/// Checks whether a process exists on the current platform.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process exists</returns>
Expand Down
3 changes: 2 additions & 1 deletion MCPForUnity/Editor/Services/Server/IProcessTerminator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public interface IProcessTerminator
/// <summary>
/// Terminates a process using platform-appropriate methods.
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
/// On Windows: Tries taskkill, then taskkill /F.
/// On Windows: Uses taskkill /F /T and briefly waits for confirmed exit.
/// Cooperative HTTP shutdown is handled separately by the server management service.
/// </summary>
/// <param name="pid">The process ID to terminate</param>
/// <returns>True if the process was terminated successfully</returns>
Expand Down
15 changes: 11 additions & 4 deletions MCPForUnity/Editor/Services/Server/ProcessDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,17 @@ public bool ProcessExists(int pid)
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// On Windows, use tasklist to check if process exists
bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000);
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
return ok && combined.Contains(pid.ToString());
try
{
using (var process = System.Diagnostics.Process.GetProcessById(pid))
{
return !process.HasExited;
}
}
catch (System.ArgumentException)
{
return false;
}
}

// Unix: ps exits non-zero when PID is not found.
Expand Down
40 changes: 27 additions & 13 deletions MCPForUnity/Editor/Services/Server/ProcessTerminator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ public bool Terminate(int pid)
string stdout, stderr;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// taskkill without /F first; fall back to /F if needed.
bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr);
if (!ok)
if (!_processDetector.ProcessExists(pid))
{
ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr);
return false;
}
return ok;
// The service handles cooperative HTTP shutdown; this is its Windows process fallback.
RunTaskKill(pid, force: true);
return WaitForProcessExit(pid, timeoutMilliseconds: 1000);
}
else
{
Expand All @@ -63,15 +63,9 @@ public bool Terminate(int pid)
if (!File.Exists(killPath)) killPath = "kill";
ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr);

// Wait briefly for graceful shutdown.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8);
while (DateTime.UtcNow < deadline)
if (WaitForProcessExit(pid))
{
if (!_processDetector.ProcessExists(pid))
{
return true;
}
System.Threading.Thread.Sleep(100);
return true;
}

// Escalate.
Expand All @@ -85,5 +79,25 @@ public bool Terminate(int pid)
return false;
}
}

internal virtual bool WaitForProcessExit(int pid, int timeoutMilliseconds = 8000)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
if (!_processDetector.ProcessExists(pid))
{
return true;
}
System.Threading.Thread.Sleep(100);
}
return false;
}

internal virtual bool RunTaskKill(int pid, bool force)
{
string arguments = force ? $"/F /PID {pid} /T" : $"/PID {pid} /T";
return ExecPath.TryRun("taskkill", arguments, Application.dataPath, out _, out _);
}
}
}
64 changes: 63 additions & 1 deletion MCPForUnity/Editor/Services/ServerManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Server;
Expand Down Expand Up @@ -86,7 +89,7 @@ private bool TryReadPidFromPidFile(string pidFilePath, out int pid)
return _pidFileManager.TryReadPid(pidFilePath, out pid);
}

private bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken)
internal virtual bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken)
{
containsToken = false;
if (pid <= 0 || string.IsNullOrEmpty(instanceToken))
Expand Down Expand Up @@ -388,6 +391,65 @@ public bool StopLocalHttpServer()
return StopLocalHttpServerInternal(quiet: false);
}

/// <inheritdoc/>
public async Task<bool> StopLocalHttpServerAsync()
{
string url = HttpEndpointUtility.GetLocalBaseUrl();
if (!IsLocalUrl(url)) return false;
if (TryGetLocalHttpServerHandshake(out var pidFile, out var token)
&& TryReadPidFromPidFile(pidFile, out int pid)
&& pid > 1 && pid != GetCurrentProcessIdSafe()
&& await RequestShutdownAsync(new Uri(url), pid, token))
{
if (!await WaitForServerExitAsync(pid))
{
// The listener may already be closed; revalidate identity before escalation.
if (!TryProcessCommandLineContainsInstanceToken(pid, token, out bool matches)
|| !matches || !TerminateProcess(pid)) return false;
}
if (TryGetLocalHttpServerHandshake(out var currentFile, out var currentToken)
&& currentFile == pidFile && currentToken == token
&& TryReadPidFromPidFile(currentFile, out int currentPid) && currentPid == pid)
{
DeletePidFile(pidFile);
ClearLocalServerPidTracking();
}
return true;
}
return StopLocalHttpServerInternal(quiet: false);
}

internal virtual async Task<bool> WaitForServerExitAsync(int pid)
{
var timer = System.Diagnostics.Stopwatch.StartNew();
while (_processDetector.ProcessExists(pid) && timer.Elapsed < TimeSpan.FromSeconds(8))
await Task.Delay(100);
return !_processDetector.ProcessExists(pid);
}

internal virtual async Task<bool> RequestShutdownAsync(Uri url, int pid, string token)
{
try
{
using (var handler = new HttpClientHandler { UseProxy = false, AllowAutoRedirect = false })
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(1) })
using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(url, "/_unity/shutdown")))
{
request.Headers.Add("X-Unity-Instance-Token", token);
using (var response = await client.SendAsync(request))
{
return response.StatusCode == System.Net.HttpStatusCode.Accepted
&& (int?)JObject.Parse(await response.Content.ReadAsStringAsync())["pid"] == pid;
}
}
}
catch (Exception ex)
{
McpLog.Debug($"Cooperative server shutdown unavailable: {ex.GetType().Name}");
return false;
}
}

public bool StopManagedLocalHttpServer()
{
if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ private async void OnHttpServerToggleClicked()
{
await bridgeService.StopAsync();
}
bool stopped = MCPServiceLocator.Server.StopLocalHttpServer();
bool stopped = await MCPServiceLocator.Server.StopLocalHttpServerAsync();
if (stopped)
{
McpLog.Info("Server stopped");
Expand Down
32 changes: 32 additions & 0 deletions Server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
import logging
from contextlib import asynccontextmanager
import os
import ipaddress
import secrets
import signal
import threading
import time
from typing import AsyncIterator, Any
Expand Down Expand Up @@ -62,6 +65,7 @@
pass

from fastmcp import FastMCP
from starlette.background import BackgroundTask
from logging.handlers import RotatingFileHandler


Expand Down Expand Up @@ -384,6 +388,34 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
custom_tool_service = CustomToolService(
mcp, project_scoped_tools=project_scoped_tools)

instance_token = os.environ.get("UNITY_MCP_INSTANCE_TOKEN", "")
shutdown_requested = False

@mcp.custom_route("/_unity/shutdown", methods=["POST"])
async def shutdown_http(request: Request) -> JSONResponse:
try:
local_client = request.client is not None and ipaddress.ip_address(request.client.host).is_loopback
except ValueError:
local_client = False
provided_token = request.headers.get("X-Unity-Instance-Token", "")
if (config.http_remote_hosted or not instance_token or not local_client
or not secrets.compare_digest(provided_token.encode(), instance_token.encode())):
return JSONResponse({"error": "Forbidden"}, status_code=403)
handler = signal.getsignal(signal.SIGINT)
if not callable(handler) or handler is signal.default_int_handler:
return JSONResponse({"error": "Shutdown handler unavailable"}, status_code=503)

async def request_exit() -> None:
nonlocal shutdown_requested
if shutdown_requested:
return
shutdown_requested = True
# In-process Ctrl+C also unwinds FastMCP's outer lifespan after Uvicorn exits.
signal.raise_signal(signal.SIGINT)

return JSONResponse({"pid": os.getpid()}, status_code=202,
background=BackgroundTask(request_exit))

@mcp.custom_route("/health", methods=["GET"])
async def health_http(_: Request) -> JSONResponse:
return JSONResponse({
Expand Down
112 changes: 112 additions & 0 deletions Server/tests/test_server_shutdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Only authenticated local requests may trigger cooperative shutdown."""
import os
import signal
from unittest.mock import Mock

import pytest
from starlette.requests import Request
from starlette.routing import Route

from core.config import config


@pytest.fixture
def shutdown_endpoint_factory(monkeypatch):
import main

# Integration collection installs a FastMCP stub; collect the real handlers independently.
server = Mock()
server._additional_http_routes = []
server._get_additional_http_routes.side_effect = lambda: list(server._additional_http_routes)

def custom_route(path, methods):
def register(handler):
server._additional_http_routes.append(Route(path, handler, methods=methods))
return handler
return register

server.custom_route.side_effect = custom_route
monkeypatch.setattr(main, "FastMCP", Mock(return_value=server))
monkeypatch.setattr(main, "CustomToolService", Mock())
monkeypatch.setattr(main, "custom_tool_service", None)
monkeypatch.setattr(main, "get_unity_instance_middleware", Mock())
monkeypatch.setattr(main, "register_all_tools", Mock())
monkeypatch.setattr(main, "register_all_resources", Mock())

def create_endpoint():
app = main.create_mcp_server(False)
return next(r.endpoint for r in app._get_additional_http_routes() if r.path == "/_unity/shutdown")

return create_endpoint


@pytest.fixture
def shutdown(monkeypatch, shutdown_endpoint_factory):
monkeypatch.setenv("UNITY_MCP_INSTANCE_TOKEN", "launch-token")
monkeypatch.setattr(config, "http_remote_hosted", False)
raised = Mock()
monkeypatch.setattr(signal, "getsignal", lambda _: Mock())
monkeypatch.setattr(signal, "raise_signal", raised)
return shutdown_endpoint_factory(), raised


def request(host="127.0.0.1", token="launch-token"):
return Request({"type": "http", "method": "POST", "path": "/_unity/shutdown",
"client": (host, 1234), "headers": [(b"x-unity-instance-token", token.encode())]})


@pytest.mark.asyncio
@pytest.mark.parametrize("host", ["127.0.0.1", "::1"])
async def test_shutdown_defers_signal_until_after_response(shutdown, host):
endpoint, raised = shutdown
response = await endpoint(request(host))
assert response.status_code == 202
assert str(os.getpid()).encode() in response.body
raised.assert_not_called()
await response.background()
raised.assert_called_once_with(signal.SIGINT)


@pytest.mark.asyncio
@pytest.mark.parametrize("host,token", [
("127.0.0.1", ""), ("127.0.0.1", "wrong"), ("127.0.0.1", "\u00e9"),
("192.0.2.1", "launch-token"), ("unknown", "launch-token"),
])
async def test_shutdown_rejects_untrusted_requests(shutdown, host, token):
endpoint, raised = shutdown
assert (await endpoint(request(host, token))).status_code == 403
raised.assert_not_called()


@pytest.mark.asyncio
async def test_shutdown_rejects_remote_hosted_server(shutdown, monkeypatch):
endpoint, raised = shutdown
monkeypatch.setattr(config, "http_remote_hosted", True)
assert (await endpoint(request())).status_code == 403
raised.assert_not_called()


@pytest.mark.asyncio
@pytest.mark.parametrize("handler", [signal.SIG_DFL, signal.default_int_handler])
async def test_shutdown_requires_installed_handler(shutdown, monkeypatch, handler):
endpoint, raised = shutdown
monkeypatch.setattr(signal, "getsignal", lambda _: handler)
assert (await endpoint(request())).status_code == 503
raised.assert_not_called()


@pytest.mark.asyncio
async def test_repeated_shutdown_does_not_force_uvicorn_exit(shutdown):
endpoint, raised = shutdown
first = await endpoint(request())
second = await endpoint(request())
await first.background()
await second.background()
raised.assert_called_once_with(signal.SIGINT)


@pytest.mark.asyncio
async def test_shutdown_disabled_without_launch_token(monkeypatch, shutdown_endpoint_factory):
monkeypatch.delenv("UNITY_MCP_INSTANCE_TOKEN", raising=False)
endpoint = shutdown_endpoint_factory()
assert (await endpoint(request())).status_code == 403
Loading