Skip to content
Closed
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 90 additions & 6 deletions Editor/MCP/Server/HttpMCPTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<MCPRequest, Action<MCPResponse>> OnRequestReceived;

public HttpMCPTransport(int port)
public HttpMCPTransport(int port, string expectedServerName = null)
{
_port = port;
_expectedServerName = expectedServerName;
}

public async Task<bool> StartAsync(CancellationToken ct = default)
Expand All @@ -50,6 +55,7 @@ public async Task<bool> StartAsync(CancellationToken ct = default)

_cts = new CancellationTokenSource();
_isRunning = true;
_ownsListener = true;

_ = Task.Run(() => ListenLoopAsync(_cts.Token), _cts.Token);

Expand All @@ -62,9 +68,22 @@ public async Task<bool> 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(
Expand Down Expand Up @@ -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)
{
Expand All @@ -115,6 +141,7 @@ public void Stop()
finally
{
_isRunning = false;
_ownsListener = false;
_listener = null;
_cts?.Dispose();
_cts = null;
Expand All @@ -134,7 +161,64 @@ private void CleanupFailedStart()
finally
{
_listener = null;
_ownsListener = false;
}
}

private async Task<bool> 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<string> 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)
Expand Down
5 changes: 3 additions & 2 deletions Editor/MCP/Server/MCPServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ private async Task<bool> 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);
Expand All @@ -173,7 +174,7 @@ private async Task<bool> StartCoreAsync(int lifecycleVersion, CancellationTokenS
executionBridge,
resourceProvider,
promptProvider,
"Funplay MCP Server - " + Application.productName,
serverName,
PackageVersionUtility.CurrentVersion);

transport.OnRequestReceived += HandleRequestReceived;
Expand Down
8 changes: 8 additions & 0 deletions Editor/Properties.meta

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

5 changes: 5 additions & 0 deletions Editor/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (C) Funplay. Licensed under MIT.

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Funplay.Editor.Tests")]
11 changes: 11 additions & 0 deletions Editor/Properties/AssemblyInfo.cs.meta

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

8 changes: 8 additions & 0 deletions Tests.meta

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

8 changes: 8 additions & 0 deletions Tests/Editor.meta

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

25 changes: 25 additions & 0 deletions Tests/Editor/Funplay.Editor.Tests.asmdef
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions Tests/Editor/Funplay.Editor.Tests.asmdef.meta

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

111 changes: 111 additions & 0 deletions Tests/Editor/HttpMCPTransportLifecycleTests.cs
Original file line number Diff line number Diff line change
@@ -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<MCPResponse> 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<string, object>
{
["serverInfo"] = new Dictionary<string, object>
{
["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<string> 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();
}
}
}
}
11 changes: 11 additions & 0 deletions Tests/Editor/HttpMCPTransportLifecycleTests.cs.meta

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