feat: Add beta server mode with PyPI pre-release support (#640)

* feat: add TestPyPI toggle for pre-release server package testing

- Add UseTestPyPI editor preference key
- Add TestPyPI toggle to Advanced settings UI with tooltip
- Configure uvx to use test.pypi.org when TestPyPI mode enabled
- Skip version pinning in TestPyPI mode to get latest pre-release
- Update ConfigJsonBuilder to handle TestPyPI index URL

* Update .meta file

* fix: Use PyPI pre-release versions instead of TestPyPI for beta server

TestPyPI has polluted packages (broken httpx, mcp, fastapi) that cause
server startup failures. Switch to publishing beta versions directly to
PyPI as pre-releases (e.g., 9.3.0b20260127).

Key changes:
- beta-release.yml: Publish to PyPI instead of TestPyPI, use beta suffix
- Use --prerelease explicit with version specifier (>=0.0.0a0) to only
  get prereleases of our package, not broken dependency prereleases
- Default "Use Beta Server" toggle to true on beta branch
- Rename UI label from "Use TestPyPI" to "Use Beta Server"
- Add UseTestPyPI to EditorPrefsWindow known prefs
- Add search field and refresh button to EditorPrefsWindow

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: Add beta mode indicator to UI badge and server version logging

- Show "β" suffix on version badge when beta server mode is enabled
- Badge updates dynamically when toggle changes
- Add server version to startup log: "MCP for Unity Server v9.2.0 starting up"
- Add version field to /health endpoint response

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: Rename UseTestPyPI to UseBetaServer and fix EditorPrefs margin

- Rename EditorPref key from UseTestPyPI to UseBetaServer for clarity
- Rename all related variables and UXML element names
- Increase bottom margin on EditorPrefs search bar to prevent clipping first entry

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: Address code review feedback

- Centralize beta server uvx args in AssetPathUtility.GetBetaServerFromArgs()
  to avoid duplication between HTTP and stdio transports
- Cache server version at startup instead of calling get_package_version()
  on every /health request
- Add robustness to beta version parsing in workflow: strip existing
  pre-release suffix and validate X.Y.Z format before parsing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Prioritize explicit fromUrl override and optimize search filter

- GetBetaServerFromArgs/GetBetaServerFromArgsList now check for explicit
  GitUrlOverride before applying beta server mode, ensuring local dev
  paths and custom URLs are honored
- EditorPrefsWindow search filter uses IndexOf with OrdinalIgnoreCase
  instead of ToLowerInvariant().Contains() for fewer allocations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Marcus Sanatan <msanatan@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
main
dsarno 2026-01-27 11:34:11 -08:00 committed by GitHub
parent 7685db8844
commit 17c6a36c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 221 additions and 35 deletions

View File

@ -1,4 +1,4 @@
name: Beta Release (TestPyPI)
name: Beta Release (PyPI Pre-release)
concurrency:
group: beta-release
@ -12,12 +12,12 @@ on:
- "Server/**"
jobs:
publish_testpypi:
name: Publish beta to TestPyPI
publish_pypi_prerelease:
name: Publish beta to PyPI (pre-release)
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/mcpforunityserver
name: pypi
url: https://pypi.org/p/mcpforunityserver
permissions:
contents: read
id-token: write
@ -33,27 +33,38 @@ jobs:
enable-cache: true
cache-dependency-glob: "Server/uv.lock"
- name: Generate dev version
- name: Generate beta version
id: version
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml)
# Use date for unique dev version (PEP 440 compliant: X.Y.Z.devN)
# Note: PyPI/TestPyPI don't support local version identifiers (+...)
DEV_NUMBER="$(date +%Y%m%d%H%M%S)"
DEV_VERSION="${BASE_VERSION}.dev${DEV_NUMBER}"
RAW_VERSION=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml)
# Strip any existing pre-release suffix (a, b, rc, dev, post) for safe parsing
# e.g., "9.2.0b1" -> "9.2.0", "9.2.0.dev1" -> "9.2.0"
BASE_VERSION=$(echo "$RAW_VERSION" | sed -E 's/(a|b|rc|\.dev|\.post)[0-9]+$//')
# Validate we have a proper X.Y.Z format
if ! [[ "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Could not parse version '$RAW_VERSION' -> '$BASE_VERSION'" >&2
exit 1
fi
# Bump minor version and use beta suffix (PEP 440 compliant: X.Y+1.0bN)
# This ensures beta is "newer" than the stable release
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
NEXT_MINOR=$((MINOR + 1))
BETA_NUMBER="$(date +%Y%m%d%H%M%S)"
BETA_VERSION="${MAJOR}.${NEXT_MINOR}.0b${BETA_NUMBER}"
echo "Raw version: $RAW_VERSION"
echo "Base version: $BASE_VERSION"
echo "Dev version: $DEV_VERSION"
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "Beta version: $BETA_VERSION"
echo "beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"
- name: Update version for beta release
env:
DEV_VERSION: ${{ steps.version.outputs.dev_version }}
BETA_VERSION: ${{ steps.version.outputs.beta_version }}
shell: bash
run: |
set -euo pipefail
sed -i "s/^version = .*/version = \"${DEV_VERSION}\"/" Server/pyproject.toml
sed -i "s/^version = .*/version = \"${BETA_VERSION}\"/" Server/pyproject.toml
echo "Updated pyproject.toml:"
grep "^version" Server/pyproject.toml
@ -62,8 +73,7 @@ jobs:
run: uv build
working-directory: ./Server
- name: Publish distribution to TestPyPI
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: Server/dist/
repository-url: https://test.pypi.org/legacy/

View File

@ -1,2 +1,11 @@
fileFormatVersion: 2
guid: 489f99ffb7e6743e88e3203552c8b37b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -28,6 +28,7 @@ namespace MCPForUnity.Editor.Constants
internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl";
internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride";
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";
internal const string UseBetaServer = "MCPForUnity.UseBetaServer";
internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp";
internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";

View File

@ -243,6 +243,83 @@ namespace MCPForUnity.Editor.Helpers
return (uvxPath, fromUrl, packageName);
}
/// <summary>
/// Builds the uvx package source arguments for the MCP server.
/// Handles beta server mode (prerelease from PyPI) vs standard mode (pinned version or override).
/// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports.
/// Priority: explicit fromUrl override > beta server mode > default package.
/// </summary>
/// <param name="quoteFromPath">Whether to quote the --from path (needed for command-line strings, not for arg lists)</param>
/// <returns>The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0")</returns>
public static string GetBetaServerFromArgs(bool quoteFromPath = false)
{
// Explicit override (local path, git URL, etc.) always wins
string fromUrl = GetMcpServerPackageSource();
string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(overrideUrl))
{
return $"--from {fromUrl}";
}
// Beta server mode: use prerelease from PyPI
bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
if (useBetaServer)
{
// Use --prerelease explicit with version specifier to only get prereleases of our package,
// not of dependencies (which can be broken on PyPI).
string fromValue = quoteFromPath ? "\"mcpforunityserver>=0.0.0a0\"" : "mcpforunityserver>=0.0.0a0";
return $"--prerelease explicit --from {fromValue}";
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(fromUrl))
{
return $"--from {fromUrl}";
}
return string.Empty;
}
/// <summary>
/// Builds the uvx package source arguments as a list (for JSON config builders).
/// Priority: explicit fromUrl override > beta server mode > default package.
/// </summary>
/// <returns>List of arguments to add to uvx command</returns>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList()
{
var args = new System.Collections.Generic.List<string>();
// Explicit override (local path, git URL, etc.) always wins
string fromUrl = GetMcpServerPackageSource();
string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(overrideUrl))
{
args.Add("--from");
args.Add(fromUrl);
return args;
}
// Beta server mode: use prerelease from PyPI
bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
if (useBetaServer)
{
args.Add("--prerelease");
args.Add("explicit");
args.Add("--from");
args.Add("mcpforunityserver>=0.0.0a0");
return args;
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(fromUrl))
{
args.Add("--from");
args.Add(fromUrl);
}
return args;
}
/// <summary>
/// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.

View File

@ -2,8 +2,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using Newtonsoft.Json;
@ -170,10 +170,11 @@ namespace MCPForUnity.Editor.Helpers
args.Add("--no-cache");
args.Add("--refresh");
}
if (!string.IsNullOrEmpty(fromUrl))
// Use centralized helper for beta server / prerelease args
foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList())
{
args.Add("--from");
args.Add(fromUrl);
args.Add(arg);
}
args.Add(packageName);

View File

@ -1317,9 +1317,13 @@ namespace MCPForUnity.Editor.Services
true
);
string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty;
string args = string.IsNullOrEmpty(fromUrl)
// Use centralized helper for beta server / prerelease args
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
string args = string.IsNullOrEmpty(fromArgs)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
: $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
: $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
fileName = uvxPath;
arguments = args;

View File

@ -26,6 +26,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
private Button clearGitUrlButton;
private Toggle debugLogsToggle;
private Toggle devModeForceRefreshToggle;
private Toggle useBetaServerToggle;
private TextField deploySourcePath;
private Button browseDeploySourceButton;
private Button clearDeploySourceButton;
@ -42,6 +43,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
public event Action OnGitUrlChanged;
public event Action OnHttpServerCommandUpdateRequested;
public event Action OnTestConnectionRequested;
public event Action<bool> OnBetaModeChanged;
public VisualElement Root { get; private set; }
@ -64,6 +66,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
clearGitUrlButton = Root.Q<Button>("clear-git-url-button");
debugLogsToggle = Root.Q<Toggle>("debug-logs-toggle");
devModeForceRefreshToggle = Root.Q<Toggle>("dev-mode-force-refresh-toggle");
useBetaServerToggle = Root.Q<Toggle>("use-beta-server-toggle");
deploySourcePath = Root.Q<TextField>("deploy-source-path");
browseDeploySourceButton = Root.Q<Button>("browse-deploy-source-button");
clearDeploySourceButton = Root.Q<Button>("clear-deploy-source-button");
@ -98,6 +101,13 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
if (forceRefreshLabel != null)
forceRefreshLabel.tooltip = devModeForceRefreshToggle.tooltip;
}
if (useBetaServerToggle != null)
{
useBetaServerToggle.tooltip = "When enabled, uvx will fetch the latest beta server version from PyPI. Enable this on the beta branch to get the matching server version.";
var betaServerLabel = useBetaServerToggle?.parent?.Q<Label>();
if (betaServerLabel != null)
betaServerLabel.tooltip = useBetaServerToggle.tooltip;
}
if (testConnectionButton != null)
testConnectionButton.tooltip = "Test the connection between Unity and the MCP server.";
if (deploySourcePath != null)
@ -128,6 +138,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
McpLog.SetDebugLoggingEnabled(debugEnabled);
devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
useBetaServerToggle.value = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
UpdatePathOverrides();
UpdateDeploymentSection();
}
@ -172,6 +183,13 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
OnHttpServerCommandUpdateRequested?.Invoke();
});
useBetaServerToggle.RegisterValueChangedCallback(evt =>
{
EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, evt.newValue);
OnHttpServerCommandUpdateRequested?.Invoke();
OnBetaModeChanged?.Invoke(evt.newValue);
});
deploySourcePath.RegisterValueChangedCallback(evt =>
{
string path = evt.newValue?.Trim();
@ -274,6 +292,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
gitUrlOverride.value = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
debugLogsToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
useBetaServerToggle.value = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
UpdateDeploymentSection();
}

View File

@ -41,6 +41,11 @@
<ui:Toggle name="dev-mode-force-refresh-toggle" class="setting-toggle" />
</ui:VisualElement>
<ui:VisualElement class="setting-row">
<ui:Label text="Use Beta Server:" class="setting-label" />
<ui:Toggle name="use-beta-server-toggle" class="setting-toggle" />
</ui:VisualElement>
<ui:VisualElement class="override-row" style="margin-top: 8px;">
<ui:Label text="Package Source:" class="override-label" />
</ui:VisualElement>

View File

@ -19,6 +19,8 @@ namespace MCPForUnity.Editor.Windows
// UI Elements
private ScrollView scrollView;
private VisualElement prefsContainer;
private TextField searchField;
private string searchFilter = "";
// Data
private List<EditorPrefItem> currentPrefs = new List<EditorPrefItem>();
@ -40,6 +42,7 @@ namespace MCPForUnity.Editor.Windows
{ EditorPrefKeys.CustomToolRegistrationEnabled, EditorPrefType.Bool },
{ EditorPrefKeys.TelemetryDisabled, EditorPrefType.Bool },
{ EditorPrefKeys.DevModeForceServerRefresh, EditorPrefType.Bool },
{ EditorPrefKeys.UseBetaServer, EditorPrefType.Bool },
{ EditorPrefKeys.ProjectScopedToolsLocalHttp, EditorPrefType.Bool },
// Integer prefs
@ -106,6 +109,38 @@ namespace MCPForUnity.Editor.Windows
visualTree.CloneTree(rootVisualElement);
// Add search bar container at the top
var searchContainer = new VisualElement();
searchContainer.style.flexDirection = FlexDirection.Row;
searchContainer.style.marginTop = 8;
searchContainer.style.marginBottom = 20;
searchContainer.style.marginLeft = 4;
searchContainer.style.marginRight = 4;
searchField = new TextField("Search");
searchField.style.flexGrow = 1;
searchField.style.height = 28;
searchField.style.paddingTop = 2;
searchField.style.paddingBottom = 2;
searchField.labelElement.style.unityFontStyleAndWeight = FontStyle.Bold;
searchField.RegisterValueChangedCallback(evt =>
{
searchFilter = evt.newValue ?? "";
RefreshPrefs();
});
var refreshButton = new Button(RefreshPrefs);
refreshButton.text = "↻";
refreshButton.tooltip = "Refresh prefs";
refreshButton.style.width = 30;
refreshButton.style.height = 28;
refreshButton.style.marginLeft = 6;
refreshButton.style.backgroundColor = new Color(0.9f, 0.5f, 0.1f);
searchContainer.Add(searchField);
searchContainer.Add(refreshButton);
rootVisualElement.Insert(0, searchContainer);
// Get references
scrollView = rootVisualElement.Q<ScrollView>("scroll-view");
prefsContainer = rootVisualElement.Q<VisualElement>("prefs-container");
@ -155,12 +190,22 @@ namespace MCPForUnity.Editor.Windows
// Sort keys
allKeys.Sort();
// Pre-trim filter once outside the loop
var filter = searchFilter?.Trim();
// Create items for existing prefs
foreach (var key in allKeys)
{
// Skip Customer UUID but show everything else that's defined
if (key != EditorPrefKeys.CustomerUuid)
{
// Apply search filter using OrdinalIgnoreCase for fewer allocations
if (!string.IsNullOrEmpty(filter) &&
key.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
var item = CreateEditorPrefItem(key);
if (item != null)
{

View File

@ -182,11 +182,7 @@ namespace MCPForUnity.Editor.Windows
}
// Initialize version label
if (versionLabel != null)
{
string version = AssetPathUtility.GetPackageVersion();
versionLabel.text = $"v{version}";
}
UpdateVersionLabel(EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true));
SetupTabs();
@ -252,6 +248,7 @@ namespace MCPForUnity.Editor.Windows
if (connectionSection != null)
await connectionSection.VerifyBridgeConnectionAsync();
};
advancedSection.OnBetaModeChanged += UpdateVersionLabel;
// Wire up health status updates from Connection to Advanced
connectionSection?.SetHealthStatusUpdateCallback((isHealthy, statusText) =>
@ -288,6 +285,20 @@ namespace MCPForUnity.Editor.Windows
RefreshAllData();
}
private void UpdateVersionLabel(bool useBetaServer)
{
if (versionLabel == null)
{
return;
}
string version = AssetPathUtility.GetPackageVersion();
versionLabel.text = useBetaServer ? $"v{version} β" : $"v{version}";
versionLabel.tooltip = useBetaServer
? "Beta server mode - fetching pre-release server versions from PyPI"
: $"MCP For Unity v{version}";
}
private void EnsureToolsLoaded()
{
if (toolsLoaded)

View File

@ -126,6 +126,9 @@ except Exception:
_unity_connection_pool: UnityConnectionPool | None = None
_plugin_registry: PluginRegistry | None = None
# Cached server version (set at startup to avoid repeated I/O)
_server_version: str | None = None
# In-memory custom tool service initialized after MCP construction
custom_tool_service: CustomToolService | None = None
@ -133,8 +136,9 @@ custom_tool_service: CustomToolService | None = None
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
"""Handle server startup and shutdown."""
global _unity_connection_pool
logger.info("MCP for Unity Server starting up")
global _unity_connection_pool, _server_version
_server_version = get_package_version()
logger.info(f"MCP for Unity Server v{_server_version} starting up")
# Register custom tool management endpoints with FastMCP
# Routes are declared globally below after FastMCP initialization
@ -158,13 +162,12 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
# Record server startup telemetry
start_time = time.time()
start_clk = time.perf_counter()
server_version = get_package_version()
# Defer initial telemetry by 1s to avoid stdio handshake interference
def _emit_startup():
try:
record_telemetry(RecordType.STARTUP, {
"server_version": server_version,
"server_version": _server_version,
"startup_time": start_time,
})
record_milestone(MilestoneType.FIRST_STARTUP)
@ -325,6 +328,7 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
return JSONResponse({
"status": "healthy",
"timestamp": time.time(),
"version": _server_version or "unknown",
"message": "MCP for Unity server is running"
})