Skip to content
Merged
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
194 changes: 194 additions & 0 deletions .github/workflows/e2e-bridge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
name: E2E Bridge Smoke (deterministic, no LLM)

# Boots a headless Unity Editor, starts the Python MCP server's wire path, and
# drives a fixed sequence of real tool calls with exact assertions
# (Server/tests/e2e/bridge_smoke.py). Unlike claude-nl-suite.yml this needs
# NO Anthropic API key -- it is deterministic and cheap, so it can gate PRs and
# releases. It still needs Unity license secrets to boot the Editor.

on:
workflow_dispatch:
pull_request:
paths:
- "MCPForUnity/Editor/**"
- "MCPForUnity/Runtime/**"
- "Server/src/**"
- "Server/tests/e2e/**"
- "tools/local_harness.py"
- ".github/workflows/e2e-bridge.yml"

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3

jobs:
e2e-bridge:
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Detect Unity license secrets
id: detect
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -e
if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ] && [ -n "$UNITY_SERIAL" ]; }; then
echo "unity_ok=true" >> "$GITHUB_OUTPUT"
else
echo "unity_ok=false" >> "$GITHUB_OUTPUT"
echo "::warning::Unity license secrets absent; E2E bridge smoke will be skipped (not failed)."
fi

- uses: actions/checkout@v4
if: steps.detect.outputs.unity_ok == 'true'
with:
fetch-depth: 0

- uses: astral-sh/setup-uv@v4
if: steps.detect.outputs.unity_ok == 'true'
with:
python-version: "3.11"
Comment on lines +51 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

wf=".github/workflows/e2e-bridge.yml"

echo "Action references in ${wf}:"
rg -n '^\s*uses:\s*' "$wf"

echo
echo "Refs that are NOT pinned to a 40-char commit SHA:"
python - <<'PY'
import pathlib, re
p = pathlib.Path(".github/workflows/e2e-bridge.yml")
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
    m = re.match(r'\s*uses:\s*([^\s]+)', line)
    if not m:
        continue
    ref = m.group(1)
    tag = ref.split('@', 1)[1] if '@' in ref else ''
    if not re.fullmatch(r'[0-9a-fA-F]{40}', tag):
        print(f"Line {i}: {ref}")
PY

Repository: CoplayDev/unity-mcp

Length of output: 251


🏁 Script executed:

#!/bin/bash
# Read the workflow file to see actual content around the referenced lines
echo "=== Lines 45-65 (around lines 51-59) ==="
sed -n '45,65p' .github/workflows/e2e-bridge.yml

echo
echo "=== Lines 185-195 (around line 190) ==="
sed -n '185,195p' .github/workflows/e2e-bridge.yml

echo
echo "=== All 'uses:' lines in the file ==="
grep -n 'uses:' .github/workflows/e2e-bridge.yml

Repository: CoplayDev/unity-mcp

Length of output: 1404


Pin third-party actions to immutable SHAs and disable checkout credential persistence.

At lines 51, 56, and 190, action refs are tag-pinned (@v4) instead of SHA-pinned, and the checkout action at line 51 retains default credential persistence. Replace version tags with full commit SHAs and add persist-credentials: false to checkout:

Example fix for checkout (lines 51–54)
      - uses: actions/checkout@<40-char-sha>
        if: steps.detect.outputs.unity_ok == 'true'
        with:
          fetch-depth: 0
          persist-credentials: false

Apply the same SHA pinning pattern to astral-sh/setup-uv@v4 (line 56) and actions/upload-artifact@v4 (line 190).

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 51-54: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 51-51: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 56-56: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e-bridge.yml around lines 51 - 59, Replace version tag
references with full commit SHAs for all third-party actions (actions/checkout,
astral-sh/setup-uv, and actions/upload-artifact) to pin them to immutable
commits instead of mutable version tags. Additionally, add `persist-credentials:
false` to the checkout action's `with` section to disable credential persistence
for improved security. Apply these changes consistently across all three action
invocations in the workflow file.

Source: Linters/SAST tools


- name: Install MCP server
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -eux
uv venv
echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
uv pip install -e Server

# --- License staging (mirrors claude-nl-suite.yml) ---
- name: Decide license sources
if: steps.detect.outputs.unity_ok == 'true'
id: lic
shell: bash
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -eu
use_ulf=false; use_ebl=false
[[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true
[[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" && -n "${UNITY_SERIAL:-}" ]] && use_ebl=true
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"

- name: Stage Unity .ulf license (from secret)
if: steps.detect.outputs.unity_ok == 'true' && steps.lic.outputs.use_ulf == 'true'
id: ulf
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
shell: bash
run: |
set -eu
mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity"
f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf"
if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then
printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f"
else
printf "%s" "$UNITY_LICENSE" > "$f"
fi
chmod 600 "$f" || true
if grep -qi '<Signature>' "$f"; then
cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf"
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
fi

- name: Activate Unity (EBL via container)
if: steps.detect.outputs.unity_ok == 'true' && steps.lic.outputs.use_ebl == 'true'
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local"
docker run --rm --network host \
-e HOME=/root -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
"$UNITY_IMAGE" bash -lc '
set -euxo pipefail
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
'

- name: Warm up project (import Library once)
if: steps.detect.outputs.unity_ok == 'true'
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
manual_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
docker run --rm --network host \
-e HOME=/root \
-v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
-v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
"${manual_args[@]}" -quit

- name: Clean old MCP status
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -eux
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true

- name: Run headless bridge harness (boot + wait + smoke/editmode/playmode)
if: steps.detect.outputs.unity_ok == 'true'
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
# In --ci mode the harness drives the DockerLauncher: it runs the same
# docker container (repo .unity-mcp status dir, docker liveness/teardown,
# log redaction), waits on the status file, derives the instance, then
# runs the smoke + EditMode + PlayMode legs over the bridge.
license_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
license_args=(--editor-arg -manualLicenseFile \
--editor-arg "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
python3 tools/local_harness.py --ci \
--legs smoke,editmode,playmode \
--project-path TestProjects/UnityMCPTests \
--reports reports \
"${license_args[@]}"

- name: Unity logs on failure
if: failure() && steps.detect.outputs.unity_ok == 'true'
run: docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true

- name: Upload E2E report
if: always() && steps.detect.outputs.unity_ok == 'true'
uses: actions/upload-artifact@v4
with:
name: e2e-bridge-report
path: reports/junit-*.xml
if-no-files-found: ignore
7 changes: 7 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ on:
branches-ignore: [beta, main]
paths:
- Server/**
- tools/**
- .github/workflows/python-tests.yml
pull_request:
branches: [main, beta]
paths:
- Server/**
- tools/**
- .github/workflows/python-tests.yml
workflow_dispatch: {}
workflow_call:
Expand Down Expand Up @@ -53,6 +55,11 @@ jobs:
cd Server
uv run pytest tests/ -v --tb=short --cov --cov-report=xml --cov-report=html --cov-report=term

- name: Run local harness unit tests (hermetic, no Unity)
run: |
cd Server
uv run python -m pytest "$GITHUB_WORKSPACE/tools/tests/" -v --tb=short

- name: Upload coverage reports
uses: codecov/codecov-action@v4
if: always()
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ tools/.unity-check-logs/

# Ignore the .claude directory, since it might contain local/project-level setting such as deny and allowlist.
/.claude
.mcp.json
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ tools/check-unity-versions.sh # compile-only across installed Unity Hu
tools/check-unity-versions.sh --full # full EditMode test run
```

#### Local headless test harness
One command boots a headless Hub-licensed Editor against `TestProjects/UnityMCPTests` and runs the smoke + EditMode + PlayMode legs over the bridge — the same entrypoint CI uses (`.github/workflows/e2e-bridge.yml`):

```bash
python tools/local_harness.py
```

Key flags: `--legs smoke,editmode,playmode` (subset to run), `--project-path` (target project, default `TestProjects/UnityMCPTests`), `--reuse` (attach to an already-resident bridge instead of booting one), `--keep-alive` (leave the Editor running after the legs), `--no-warmup` (skip the warm-up import phase).

Exit codes: `0` pass, `1` blocking-leg regression, `2` bridge unreachable / setup failure, `3` project does not compile, `4` no Unity license / Hub seat, `5` Editor binary/version not found. Requires a Hub-activated Editor locally (no ULF/serial).

### Local Development
1. Set **Server Source Override** in MCP for Unity Advanced Settings to your local `Server/` path
2. Enable **Dev Mode** checkbox to force fresh installs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public ClineConfigurator() : base(new McpClient
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
HttpTypeValue = "streamableHttp",
DefaultUnityFields = { { "disabled", false }, { "autoApprove", new object[] { } } }
})
{ }
Expand Down
27 changes: 18 additions & 9 deletions MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,29 @@ public class KiloCodeConfigurator : JsonFileMcpConfigurator
public KiloCodeConfigurator() : base(new McpClient
{
name = "Kilo Code",
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
IsVsCodeLayout = false
// Kilo Code v7.0.33+ moved MCP config out of the VS Code extension's
// globalStorage/mcp_settings.json to a CLI-style kilo.jsonc under ~/.config/kilo.
// The new schema (https://app.kilo.ai/config.json) uses an "mcp" container,
// type:"remote" for HTTP servers, type:"local" for stdio, and an "enabled" flag.
// ~/.config/kilo/kilo.jsonc on every OS (UserProfile resolves to C:\Users\<user> on Windows).
windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kilo", "kilo.jsonc"),
macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kilo", "kilo.jsonc"),
linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kilo", "kilo.jsonc"),
IsVsCodeLayout = false,
ServerContainerKey = "mcp",
HttpTypeValue = "remote",
StdioTypeValue = "local",
SchemaUrl = "https://app.kilo.ai/config.json",
DefaultUnityFields = { { "enabled", true } }
})
{ }

public override IList<string> GetInstallationSteps() => new List<string>
{
"Install Kilo Code extension in VS Code",
"Open Kilo Code settings (gear icon in sidebar)",
"Navigate to MCP Servers section and click 'Edit Global MCP Settings'\nOR open the config file at the path above",
"Paste the configuration JSON into the mcpServers object",
"Save and restart VS Code"
"Install or update Kilo Code (v7.0.33 or newer)",
"Open the Kilo Code MCP Servers view\nOR edit the config file at the path above (~/.config/kilo/kilo.jsonc)",
"Paste the configuration JSON into the \"mcp\" object",
"Save and restart Kilo Code"
};
}
}
15 changes: 10 additions & 5 deletions MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,12 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
JToken unityToken = null;
if (rootConfig != null)
{
string containerKey = string.IsNullOrEmpty(client.ServerContainerKey)
? "mcpServers" : client.ServerContainerKey;
unityToken = client.IsVsCodeLayout
? rootConfig["servers"]?["unityMCP"]
?? rootConfig["mcp"]?["servers"]?["unityMCP"]
: rootConfig["mcpServers"]?["unityMCP"];
: rootConfig[containerKey]?["unityMCP"];
}

if (unityToken is JObject unityObj)
Expand Down Expand Up @@ -360,9 +362,10 @@ public override string GetConfigureActionLabel()
=> client.status == McpStatus.Configured ? "Unregister" : "Configure";

/// <summary>
/// Removes the unityMCP entry from the client's JSON config (both VS Code-style
/// `servers` / `mcp.servers` layouts and the standard `mcpServers` layout). Leaves
/// the file in place so we don't clobber other servers the user has configured.
/// Removes the unityMCP entry from the client's JSON config (VS Code-style
/// `servers` / `mcp.servers` layouts, the standard `mcpServers` layout, or a
/// client-specific container such as Kilo's `mcp`). Leaves the file in place so we
/// don't clobber other servers the user has configured.
/// </summary>
public override void Unregister()
{
Expand Down Expand Up @@ -392,7 +395,9 @@ public override void Unregister()
}
else
{
if ((root["mcpServers"] as JObject)?.Remove("unityMCP") == true) removed = true;
string containerKey = string.IsNullOrEmpty(client.ServerContainerKey)
? "mcpServers" : client.ServerContainerKey;
if ((root[containerKey] as JObject)?.Remove("unityMCP") == true) removed = true;
}

if (removed)
Expand Down
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ internal static class EditorPrefKeys
internal const string ApiKey = "MCPForUnity.ApiKey";

internal const string AutoStartOnLoad = "MCPForUnity.AutoStartOnLoad";
internal const string HttpServerLaunchConfirmed = "MCPForUnity.HttpServerLaunchConfirmed";
internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands";
internal const string LogRecordEnabled = "MCPForUnity.LogRecordEnabled";

Expand Down
Loading
Loading