diff --git a/CHANGELOG.md b/CHANGELOG.md index c04dd1b..5e702c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Fixed +- Reuse an already-running Funplay HTTP transport for the same Unity project when a duplicate start sees the configured port as in use, while still failing normally for unrelated listeners. + ## [0.3.6] - 2026-05-21 ### Fixed diff --git a/Editor/MCP/Server/HttpMCPTransport.cs b/Editor/MCP/Server/HttpMCPTransport.cs index 000652f..0afca74 100644 --- a/Editor/MCP/Server/HttpMCPTransport.cs +++ b/Editor/MCP/Server/HttpMCPTransport.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Net; +using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -21,16 +22,20 @@ internal class HttpMCPTransport : IMCPTransport private HttpListener _listener; private CancellationTokenSource _cts; private readonly int _port; + private readonly string _expectedServerName; private bool _isRunning; + private bool _ownsListener; private const int StartRetryAttempts = 40; private const int StartRetryDelayMs = 250; + private const int ExistingServerProbeTimeoutMs = 500; public bool IsRunning => _isRunning; public event Action> OnRequestReceived; - public HttpMCPTransport(int port) + public HttpMCPTransport(int port, string expectedServerName = null) { _port = port; + _expectedServerName = expectedServerName; } public async Task StartAsync(CancellationToken ct = default) @@ -50,6 +55,7 @@ public async Task StartAsync(CancellationToken ct = default) _cts = new CancellationTokenSource(); _isRunning = true; + _ownsListener = true; _ = Task.Run(() => ListenLoopAsync(_cts.Token), _cts.Token); @@ -62,9 +68,22 @@ public async Task StartAsync(CancellationToken ct = default) _isRunning = false; return false; } - catch (Exception ex) when (IsAddressInUse(ex) && attempt < StartRetryAttempts) + catch (Exception ex) when (IsAddressInUse(ex)) { CleanupFailedStart(); + + if (await TryAttachToExistingFunplayServerAsync(ct)) + { + return true; + } + + if (attempt >= StartRetryAttempts) + { + Debug.LogError($"[Funplay MCP Server] Failed to start HTTP transport: {ex.Message}"); + _isRunning = false; + return false; + } + if (attempt == 1) { Debug.LogWarning( @@ -99,10 +118,17 @@ public void Stop() try { - _cts?.Cancel(); - _listener?.Stop(); - _listener?.Close(); - PluginDebugLogger.Log("[Funplay MCP Server] HTTP transport stopped"); + if (_ownsListener) + { + _cts?.Cancel(); + _listener?.Stop(); + _listener?.Close(); + PluginDebugLogger.Log("[Funplay MCP Server] HTTP transport stopped"); + } + else if (_isRunning) + { + PluginDebugLogger.Log("[Funplay MCP Server] Detached from existing HTTP transport"); + } } catch (ObjectDisposedException) { @@ -115,6 +141,7 @@ public void Stop() finally { _isRunning = false; + _ownsListener = false; _listener = null; _cts?.Dispose(); _cts = null; @@ -134,7 +161,64 @@ private void CleanupFailedStart() finally { _listener = null; + _ownsListener = false; + } + } + + private async Task TryAttachToExistingFunplayServerAsync(CancellationToken ct) + { + try + { + var responseText = await SendInitializeProbeAsync(ct); + + if (!IsExpectedFunplayServer(responseText, _expectedServerName)) + return false; + + _isRunning = true; + _ownsListener = false; + PluginDebugLogger.Log( + $"[Funplay MCP Server] Reusing existing HTTP transport on http://127.0.0.1:{_port}/"); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return false; + } + } + + private async Task SendInitializeProbeAsync(CancellationToken ct) + { + var body = "{\"jsonrpc\":\"2.0\",\"id\":\"funplay-existing-server-probe\",\"method\":\"initialize\",\"params\":{}}"; + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + using (var client = new HttpClient { Timeout = TimeSpan.FromMilliseconds(ExistingServerProbeTimeoutMs) }) + using (var content = new StringContent(body, Encoding.UTF8, "application/json")) + { + timeoutCts.CancelAfter(ExistingServerProbeTimeoutMs); + var response = await client.PostAsync($"http://127.0.0.1:{_port}/", content, timeoutCts.Token); + if (response.StatusCode != HttpStatusCode.OK) + return string.Empty; + + return await response.Content.ReadAsStringAsync(); + } + } + + private static bool IsExpectedFunplayServer(string responseText, string expectedServerName) + { + if (string.IsNullOrEmpty(responseText) || + responseText.IndexOf("\"serverInfo\"", StringComparison.OrdinalIgnoreCase) < 0 || + responseText.IndexOf("\"name\"", StringComparison.OrdinalIgnoreCase) < 0) + { + return false; } + + if (!string.IsNullOrEmpty(expectedServerName)) + return responseText.IndexOf(expectedServerName, StringComparison.Ordinal) >= 0; + + return responseText.IndexOf("Funplay MCP Server", StringComparison.Ordinal) >= 0; } private static bool IsAddressInUse(Exception ex) diff --git a/Editor/MCP/Server/MCPServerService.cs b/Editor/MCP/Server/MCPServerService.cs index 238a526..8941902 100644 --- a/Editor/MCP/Server/MCPServerService.cs +++ b/Editor/MCP/Server/MCPServerService.cs @@ -163,7 +163,8 @@ private async Task StartCoreAsync(int lifecycleVersion, CancellationTokenS var toolExposureSetting = BuildToolExposureSetting(); PluginDebugLogger.Log("[Funplay MCP Server] Starting server..."); - transport = new HttpMCPTransport(startupPort); + var serverName = "Funplay MCP Server - " + Application.productName; + transport = new HttpMCPTransport(startupPort, serverName); var toolExporter = new MCPToolExporter(_settings); var executionBridge = new MCPExecutionBridge(_threadHelper, _settings, _stateController, _invoker, InteractionLog); resourceProvider = new MCPResourceProvider(_contextBuilder, _applicationPaths, InteractionLog); @@ -173,7 +174,7 @@ private async Task StartCoreAsync(int lifecycleVersion, CancellationTokenS executionBridge, resourceProvider, promptProvider, - "Funplay MCP Server - " + Application.productName, + serverName, PackageVersionUtility.CurrentVersion); transport.OnRequestReceived += HandleRequestReceived; diff --git a/Editor/Properties.meta b/Editor/Properties.meta new file mode 100644 index 0000000..7e16d73 --- /dev/null +++ b/Editor/Properties.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6fe22e71e01544c08d8ba7e195f72b2d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Properties/AssemblyInfo.cs b/Editor/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..5f96040 --- /dev/null +++ b/Editor/Properties/AssemblyInfo.cs @@ -0,0 +1,5 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Funplay.Editor.Tests")] diff --git a/Editor/Properties/AssemblyInfo.cs.meta b/Editor/Properties/AssemblyInfo.cs.meta new file mode 100644 index 0000000..71e6dc5 --- /dev/null +++ b/Editor/Properties/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c3e7f4ca96f24a2f9c49b3d1bdfd9b12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..e90e254 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 653e664017f64f96a8e6e676a78f6851 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..b86980f --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3c4d4f412c62420fb0ea544d13ebaa19 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/Funplay.Editor.Tests.asmdef b/Tests/Editor/Funplay.Editor.Tests.asmdef new file mode 100644 index 0000000..7e5dd65 --- /dev/null +++ b/Tests/Editor/Funplay.Editor.Tests.asmdef @@ -0,0 +1,25 @@ +{ + "name": "Funplay.Editor.Tests", + "rootNamespace": "Funplay.Editor.Tests", + "references": [ + "Funplay.Editor", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "noEngineReferences": false +} diff --git a/Tests/Editor/Funplay.Editor.Tests.asmdef.meta b/Tests/Editor/Funplay.Editor.Tests.asmdef.meta new file mode 100644 index 0000000..5e9bce8 --- /dev/null +++ b/Tests/Editor/Funplay.Editor.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 30f240fbd8d44a06b4ba650df6051e5e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/HttpMCPTransportLifecycleTests.cs b/Tests/Editor/HttpMCPTransportLifecycleTests.cs new file mode 100644 index 0000000..281e02a --- /dev/null +++ b/Tests/Editor/HttpMCPTransportLifecycleTests.cs @@ -0,0 +1,111 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Funplay.Editor.MCP.Server; +using NUnit.Framework; +using UnityEngine; + +namespace Funplay.Editor.Tests +{ + public sealed class HttpMCPTransportLifecycleTests + { + [Test] + public async Task StartAsync_AttachesToExistingSameProjectFunplayServer() + { + var port = GetFreeTcpPort(); + var serverName = "Funplay MCP Server - " + Application.productName; + var firstTransport = new HttpMCPTransport(port, serverName); + var secondTransport = new HttpMCPTransport(port, serverName); + + firstTransport.OnRequestReceived += HandleInitializeRequest; + + try + { + Assert.IsTrue(await firstTransport.StartAsync(), "The first transport should bind a free port."); + + var stopwatch = Stopwatch.StartNew(); + Assert.IsTrue( + await secondTransport.StartAsync(), + "A second transport for the same project should attach to the existing Funplay server instead of timing out."); + stopwatch.Stop(); + + Assert.Less( + stopwatch.Elapsed, + TimeSpan.FromSeconds(2), + "Attach should avoid the 10 second address-in-use retry window."); + + secondTransport.Stop(); + + Assert.That( + await SendInitializeRequestAsync(port), + Does.Contain("Funplay MCP Server - " + Application.productName), + "Stopping an attached transport must not stop the owning listener."); + } + finally + { + secondTransport.Dispose(); + firstTransport.Dispose(); + } + } + + private static void HandleInitializeRequest(MCPRequest request, Action sendResponse) + { + if (request.Method != "initialize") + { + sendResponse(new MCPResponse + { + Id = request.Id, + Error = new MCPError { Code = -32601, Message = "Method not found" } + }); + return; + } + + sendResponse(new MCPResponse + { + Id = request.Id, + Result = new Dictionary + { + ["serverInfo"] = new Dictionary + { + ["name"] = "Funplay MCP Server - " + Application.productName, + ["version"] = "test" + } + } + }); + } + + private static int GetFreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static async Task SendInitializeRequestAsync(int port) + { + using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(1) }) + using (var content = new StringContent( + "{\"jsonrpc\":\"2.0\",\"id\":\"test\",\"method\":\"initialize\",\"params\":{}}", + Encoding.UTF8, + "application/json")) + { + var response = await client.PostAsync($"http://127.0.0.1:{port}/", content); + return await response.Content.ReadAsStringAsync(); + } + } + } +} diff --git a/Tests/Editor/HttpMCPTransportLifecycleTests.cs.meta b/Tests/Editor/HttpMCPTransportLifecycleTests.cs.meta new file mode 100644 index 0000000..13764ca --- /dev/null +++ b/Tests/Editor/HttpMCPTransportLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0be09e416894481e9764f65d31d7255a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: