diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0022f25..dad326a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -102,7 +102,11 @@ jobs: documentation-quality-ai: name: Documentation quality AI - runs-on: [self-hosted, macOS, ARM64, ci-scope-broker, ci-scope-heavy] + # ci-scope-job-doc-ai disambiguates this from unity-package-smoke below: + # two same-run jobs with byte-identical runs-on let GitHub's scheduler + # hand a connecting runner EITHER one, not necessarily the one the broker + # leased it for (NexusUnity PR #145, 2026-07-04). + runs-on: [self-hosted, macOS, ARM64, ci-scope-broker, ci-scope-heavy, ci-scope-job-doc-ai] needs: static-validation env: NEXUS_DOC_AI_MAX_PARALLEL: "1" @@ -188,7 +192,9 @@ jobs: unity-package-smoke: name: Unity package smoke - runs-on: [self-hosted, macOS, ARM64, ci-scope-broker, ci-scope-heavy] + # ci-scope-job-unity-smoke disambiguates this from documentation-quality-ai + # above — see that job's comment. + runs-on: [self-hosted, macOS, ARM64, ci-scope-broker, ci-scope-heavy, ci-scope-job-unity-smoke] needs: static-validation steps: - name: Check out repository diff --git a/.gitignore b/.gitignore index d22cddb..04c5246 100644 --- a/.gitignore +++ b/.gitignore @@ -119,7 +119,9 @@ repro/ # Local agent and analysis artifacts .agents/ .codex/ +.gemini/ .hermes/ +.serena/ .soma/ graphify-out/ graphify-out/cache/ @@ -133,3 +135,4 @@ graphify-out/.graphify_* LINT_REPORT.txt temp_verify/ *.tmp +AUDIT_FINDINGS.md* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fa5b301 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# Agent Instructions: NexusUnity + +## Repository Scope + +This directory is the canonical Nexus Unity package repository. + +Use this directory for all Nexus Unity package work: + +- Branches, commits, pushes, pull requests, and issue fixes. +- Package source, bridge source, tests, changelog, README, and documentation. +- GitHub issue/PR work for `ForkHorizon/NexusUnity`. + +The parent Unity project is only the local test harness: + +`/Users/daliys/Daliys/UnityProjects/UnityTestForNexus` + +Use the parent project to run the Unity Editor, MCP server, scenes, `unity_lint_project`, and live package validation. Do not modify parent project files unless the user explicitly asks for harness work. + +## Branching + +- Base branch: `development`. +- Verify the real branch names before creating history. +- Do not auto-push. + +## Required Package Updates + +When package behavior or public API changes, keep these aligned: + +- `CHANGELOG.md` +- `README.md` +- `DOCUMENTATION.MD` +- `API_REFERENCE.MD` when tool contracts or public API shapes change +- Relevant tests under `Tests~/` or `Editor/tests/` + +## Validation + +Fast package validation: + +```bash +PYTHONDONTWRITEBYTECODE=1 scripts/prepush-validate.sh --static-only +``` + +For Unity-facing behavior, also use the running test harness through MCP tools such as `unity_wait`, `unity_lint_project`, and `unity_editor_controller`. + +Clean generated Python caches before validation if needed; `__pycache__` or `*.pyc` files are not part of package changes. diff --git a/Editor/nexus_bridge/_logging.py.meta b/AGENTS.md.meta similarity index 62% rename from Editor/nexus_bridge/_logging.py.meta rename to AGENTS.md.meta index f107dba..b51e162 100644 --- a/Editor/nexus_bridge/_logging.py.meta +++ b/AGENTS.md.meta @@ -1,6 +1,6 @@ fileFormatVersion: 2 -guid: 1e1af6fe89434648ac244b6ab4442915 -DefaultImporter: +guid: e981a827e1664e38ba1ef3562ce33638 +TextScriptImporter: externalObjects: {} userData: assetBundleName: diff --git a/API_REFERENCE.MD b/API_REFERENCE.MD index 26600e9..33f3971 100644 --- a/API_REFERENCE.MD +++ b/API_REFERENCE.MD @@ -1,6 +1,6 @@ # Nexus Unity API Reference -Version: `1.4.2` +Version: `1.5.0` Nexus Unity exposes two supported public API surfaces: @@ -14,10 +14,11 @@ Raw requests use unprefixed method names: ```bash curl -s http://127.0.0.1:8081/ \ -H 'Content-Type: application/json' \ + -H "X-Nexus-Unity-Token: $NEXUS_UNITY_AUTH_TOKEN" \ -d '{"jsonrpc":"2.0","method":"get_server_status","params":{},"id":1}' ``` -Call `list_tools` at runtime for full JSON schemas. MCP bridge aliases use the `unity_` prefix for the same raw method when directly routed. +Call `list_tools` at runtime for full JSON schemas. Generated MCP configs set `NEXUS_UNITY_AUTH_TOKEN` for the Python bridge; rerun Auto Setup after restarting Unity if the token is stale. MCP bridge aliases use the `unity_` prefix for the same raw method when directly routed. Schema compatibility notes: @@ -25,7 +26,12 @@ Schema compatibility notes: - `update_component` accepts the preferred `properties` object and the legacy `json_data` string form. - `create_scene` accepts optional `path` and `open_if_exists`, so agents can create or reopen a deterministic scene asset in one call. - `create_primitive` accepts optional `name`, `parent_id`, `position`, `rotation`, `scale`, and `material_path` for visible non-origin object creation. +- Vector3 fields accept either `{ "x": 0, "y": 1, "z": 0 }` or `[0, 1, 0]`. - `set_transform` accepts `position`, `rotation` / `eulerAngles`, and `scale` / `localScale`. +- `get_game_object` returns `transform.position`, `transform.rotation`, `transform.scale`, and compact `components` data for cheap read-back verification. +- Fast-path health calls (`get_server_status`, `attach_existing_session`, `wait_for_asset_import_idle`, and `wait_for_editor_idle`) use cached editor state because they may run on the listener thread. +- After script writes, readiness probes remain busy until the scheduled asset refresh window has passed. +- During Play Mode transitions, readiness probes remain busy until Unity finishes entering or exiting Play Mode. - `create_material` accepts optional `path`, `base_color` / `color`, and `emission_color` so callers can create visible materials in a chosen folder. - `write_file` and `write_files_batch` create missing parent directories after path validation. - `invoke_method.arguments` is an optional JSON array of positional arguments. @@ -151,14 +157,11 @@ Schema compatibility notes: - `ui_click` / `unity_ui_click` - `ui_input_text` / `unity_ui_input_text` -## MCP Bridge Tools - -The MCP bridge is the recommended AI-client surface. It exposes 14 tools and two static read-only resources through `resources/list`. +`batch_execute` accepts at most 50 requests and rejects nested `batch_execute` calls. -### MCP Resources +## MCP Bridge Tools -- `unity://docs/api-reference`: API reference metadata. -- `unity://docs/setup`: setup guide metadata. +The MCP bridge is the recommended AI-client surface. It exposes 14 tools and two static read-only resources through `resources/list` and `resources/read`. ### `unity_scene_manager` Actions: `create`, `open`, `save`, `list`. Aliases: `create_scene`, `open_scene`, `save_scene`, `list_scenes`. @@ -168,7 +171,7 @@ Actions: `create`, `open`, `save`, `list`. Aliases: `create_scene`, `open_scene` ### `unity_hierarchy_manager` Actions: `create_empty`, `create_primitive`, `create_hierarchy`, `destroy`, `duplicate`, `rename`, `set_name`, `set_transform`, `set_active`, `set_parent`, `set_sibling_index`. -Aliases: `create`, `create_gameobject`, and `create_game_object` map to `create_empty`. `create_primitive` accepts `name`, `parent_id`, `position`, `rotation`, `scale`, and `material_path`. +Aliases: `create`, `create_gameobject`, and `create_game_object` map to `create_empty`. `create_primitive` accepts `name`, `parent_id`, `position`, `rotation`, `scale`, and `material_path`; Vector3 fields accept either object or array form. ### `unity_component_manager` Actions: `add`, `remove`, `inspect`, `get_schema`, `update_properties`, `set_property`, `set_enabled`. @@ -197,10 +200,14 @@ Conditions: `compilation`, `play_mode`, `import`, `editor_idle`. ### `unity_playerprefs_manager` Actions: `get`, `set`, `delete`, `list`. +Deleting every PlayerPrefs entry requires `action: "delete"`, `key: "all"`, and `confirm: true`; this operation is not undoable. + ### `unity_write_and_compile` Writes one or more files, waits for Unity compilation/import work, and returns compiler errors. -This is the public MCP bridge macro for code edits. Use it instead of old internal or stale documentation references to `unity_apply_code_change`. +This is the public MCP bridge macro for code edits. Pass `confirm: true` when writing `.cs` files because Unity compilation is triggered. Use it instead of old internal or stale documentation references to `unity_apply_code_change`. + +Raw `attach_script`, `write_file`, and `write_files_batch` calls also require `confirm: true` before writing `.cs` files. ### `unity_invoke_method` Invokes a C# method on a component through reflection. diff --git a/CHANGELOG.md b/CHANGELOG.md index 49264f4..9f80c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable public changes to Nexus Unity are documented here. ## [Unreleased] +## [1.5.0] - 2026-07-12 + +### Changed +- Relicensed Nexus Unity from `GPL-3.0-only` to the `MIT` license to remove copyleft friction for commercial Unity studios. All prior contributors consented to the relicense. + +### Fixed +- Updated Claude Code and Gemini CLI setup commands for their current option ordering and explicit project-scoped stdio registration; Gemini setup no longer bypasses tool approvals with `--trust`. +- Antigravity setup now writes its workspace configuration to `.agents/mcp_config.json` instead of the legacy global Gemini config path. +- Cline setup now writes its shared MCP settings to `~/.cline/data/settings/cline_mcp_settings.json` instead of the retired VS Code extension storage path. +- Claude Code setup now skips the CLI add command when removal of a stale project registration fails, logs the actionable failure, and falls back directly to `.mcp.json`. +- The local HTTP/WebSocket control plane now requires a per-session auth token before JSON-RPC dispatch; generated MCP configs pass the token through the Python bridge. +- `add_component` now returns a clear `GameObject not found` error for stale instance IDs instead of throwing a raw Unity null reference. +- `batch_execute` now caps batches at 50 requests and rejects nested batch execution. +- `create_primitive` now validates parent, transform, and material inputs before creating the GameObject, and Vector3 inputs accept `[x, y, z]` arrays as well as `{x, y, z}` objects. +- `get_game_object` now returns transform state and a compact component list so agents can verify basic write operations with the cheap read-back call. +- Script writes now keep readiness probes in a busy/importing state while the scheduled asset refresh is pending, avoiding premature follow-up write calls during Unity domain reload. +- Script-writing RPC methods now require `confirm: true` before creating or overwriting `.cs` files and triggering Unity compilation. +- MCP bridge static resources can now be read through `resources/read` after discovery through `resources/list`. +- Play mode transitions now keep readiness probes busy so agents do not issue follow-up writes while Unity is still entering or exiting Play Mode. +- `delete_player_pref` now rejects missing or empty keys, and only clears all PlayerPrefs when called with `key: "all"` and `confirm: true`. +- Invalid `NEXUS_UNITY_TIMEOUT_SECONDS` values now fall back to the default bridge timeout instead of crashing the Python bridge at import time. +- Python bridge type helpers no longer require Python 3.11-only typing features. +- Path security tests now exercise JSON-RPC traversal rejection for representative file and asset tools. +- `get_test_results` now reads messages only from scoped NUnit result nodes and falls back to the test result instead of unrelated nested messages. +- `unity_scene_manager` schema aliases now preserve action-specific required parameters. +- Fast-path health JSON-RPC methods now use cached editor state instead of direct Unity API reads on the listener thread. +- MCP CLI installers now pass executable paths and project paths as process arguments instead of shell command strings. + ## [1.4.2] - 2026-06-13 ### Added diff --git a/DOCUMENTATION.MD b/DOCUMENTATION.MD index e10207e..f7ad418 100644 --- a/DOCUMENTATION.MD +++ b/DOCUMENTATION.MD @@ -1,6 +1,6 @@ # Nexus Unity Technical Documentation -Version: `1.4.2` +Version: `1.5.0` Nexus Unity is a Unity Editor automation package with two public interfaces: @@ -24,6 +24,7 @@ The server: - Restarts after Unity domain reloads when the previous session was running. - Tracks session id, session generation, last heartbeat, play mode, compile/import state, and last error. - Provides `get_server_status`, `ping_main_thread`, `wait_for_asset_import_idle`, `wait_for_editor_idle`, `get_test_results`, `get_tool_usage_stats`, and `reset_tool_usage_stats` for robust automation loops. +- Serves fast-path health and wait calls from cached editor state when they run on the listener thread. ## Unity Editor Menu @@ -42,12 +43,14 @@ Console logging defaults to `Important`, which keeps the Unity Console focused o The `Integrations` tab builds all snippets from the resolved Python interpreter and the project-root bridge path `nexus_unity_bridge.py`. Auto setup deploys the bridge first. JSON-style clients use `mcpServers.nexus-unity`, VS Code-compatible clients use `.vscode/mcp.json` with `servers.nexus-unity`, Claude Code uses a project-root `.mcp.json` with `mcpServers.nexus-unity`, and Codex uses `[mcp_servers.nexus-unity]` in TOML. -Claude Code and Claude Desktop are distinct products with distinct config locations. The Claude Code card registers `nexus-unity` in `/.mcp.json` (via the `claude` CLI when it is on `PATH`, otherwise by writing the file directly); the Claude Desktop card writes the desktop app config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows). Claude Code does not read the desktop config. +Claude Code and Claude Desktop are distinct products with distinct config locations. The Claude Code card registers `nexus-unity` in `/.mcp.json` (via the `claude` CLI when it is on `PATH`, otherwise by writing the file directly). If the CLI cannot remove a stale registration, setup skips its add command and writes `.mcp.json` directly instead; the CLI error is logged for diagnosis. The Claude Desktop card writes the desktop app config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows). Claude Code does not read the desktop config. + +Gemini CLI uses a project-scoped stdio registration in `.gemini/settings.json`; Auto Setup keeps Gemini's normal tool-approval prompts enabled. Antigravity uses the workspace-local `.agents/mcp_config.json` with the standard `mcpServers` object, not the older shared Gemini configuration path. Cline uses `~/.cline/data/settings/cline_mcp_settings.json` for shared MCP settings. ### Platform notes - The Python interpreter is resolved in the order `python3`, then `python`, then the Windows `py` launcher. On Windows, where `python3` is frequently absent from `PATH`, the generated config falls back to a discovered `python.exe` or `py`. Ensure a Python 3 interpreter is installed and on `PATH`. -- CLI-managed clients (Codex, Gemini, Antigravity, and the Claude Code CLI path) are discovered with `which` on macOS/Linux and `where` on Windows. The matching CLI must be on the `PATH` of the environment that launched the Unity Editor for `Auto Setup` to detect it; otherwise use `Copy Config` or the file-based card. +- CLI-managed clients (Codex, Gemini, and the Claude Code CLI path) are discovered with `which` on macOS/Linux and `where` on Windows. The matching CLI must be on the `PATH` of the environment that launched the Unity Editor for `Auto Setup` to detect it; otherwise use `Copy Config` or the file-based card. Integration status checks compare the package bridge with the project-root deployed bridge and the client config path. A card reports `Outdated` when the config points at another Unity project, the deployed bridge is missing, or the deployed bridge version differs from the package bridge version. Running `Auto Setup` redeploys `nexus_unity_bridge.py` plus `nexus_bridge/`, rewrites the client entry, and requires restarting the external MCP client session. @@ -58,8 +61,11 @@ When Nexus Unity writes an existing user or project config, it preserves unrelat Nexus Unity is a local developer tool and should be used only with trusted local clients. - HTTP and WebSocket requests must target loopback hosts. +- HTTP and WebSocket requests require the per-session `X-Nexus-Unity-Token` header; generated MCP configs pass it to the Python bridge as `NEXUS_UNITY_AUTH_TOKEN`. - Browser origins are validated to reduce CSRF and DNS rebinding exposure; non-loopback origins are rejected. - File operations resolve paths and enforce the Unity project root boundary. +- C# script writes require `confirm: true` before creating or overwriting `.cs` files and triggering Unity compilation. +- Editor tests cover traversal rejection at the JSON-RPC boundary for representative file and asset methods. - Request payload size is capped to reduce memory exhaustion risk. - Raw write operations are intentionally powerful; prefer `unity_write_and_compile` through the MCP bridge for code edits. @@ -77,25 +83,15 @@ Manual or root-deployed path: python3 nexus_unity_bridge.py ``` -Bridge connection settings can be overridden with `NEXUS_UNITY_URL`, `NEXUS_UNITY_PORT`, and `NEXUS_UNITY_TIMEOUT_SECONDS`. A positional port argument is still supported for existing MCP client configs, and invalid port values fall back to the positional port or default port. Bridge logging defaults to `DEBUG` and can be changed with `NEXUS_UNITY_LOG_LEVEL`. - -Direct CLI mode is available for diagnostics and scripts: - -```bash -python3 nexus_unity_bridge.py scene_manager action=list -python3 nexus_unity_bridge.py hierarchy_manager action=create_primitive primitive_type=Cube position='[0,1,0]' -``` - -CLI arguments must use `key=value`; JSON-looking values are parsed before routing the call. +Bridge connection settings can be overridden with `NEXUS_UNITY_URL`, `NEXUS_UNITY_PORT`, `NEXUS_UNITY_TIMEOUT_SECONDS`, and `NEXUS_UNITY_AUTH_TOKEN`. A positional port argument is still supported for existing MCP client configs, and invalid port values fall back to the positional port or default port. Bridge logging defaults to `DEBUG` and can be changed with `NEXUS_UNITY_LOG_LEVEL`. The bridge is modular: - `nexus_unity_bridge.py`: MCP loop and CLI entry point. -- `nexus_bridge/_logging.py`: shared bridge logger. - `nexus_bridge/_transport.py`: Unity JSON-RPC URL, port, timeout, and HTTP transport helpers. - `nexus_bridge/client.py`: HTTP JSON-RPC client. - `nexus_bridge/routing.py`: manager routing and macro behavior. -- `nexus_bridge/schemas.py`: public MCP tool schemas and static read-only MCP resources. +- `nexus_bridge/schemas.py`: public MCP tool schemas and static read-only MCP resources served through `resources/list` and `resources/read`. When deploying from the Unity UI, Nexus Unity copies both `nexus_unity_bridge.py` and the full `nexus_bridge/` package to the project root so the bridge can run from a stable path. @@ -107,7 +103,8 @@ Raw HTTP JSON-RPC: - Intended for diagnostics, custom integrations, and direct automation. - The complete raw catalog is returned by `list_tools`. - Runtime schemas intentionally include compatibility fields where needed; for example `update_component` accepts both `properties` and the legacy `json_data` payload, `create_scene` accepts `path` / `open_if_exists`, `create_primitive` accepts transform/material fields, and `create_material` accepts an optional explicit asset `path` plus visible color fields. -- Agent diagnostics include `get_test_results` for Unity `TestResults*.xml` summaries, `get_tool_usage_stats` / `reset_tool_usage_stats` for scoped in-memory call counters and timing, and `ui_get_window_rect` / `ui_set_window_rect` / `ui_capture_window_snapshot` for resize-based editor UI QA. +- Agent diagnostics include `get_test_results` for Unity `TestResults*.xml` summaries with scoped non-passing test messages, `get_tool_usage_stats` / `reset_tool_usage_stats` for scoped in-memory call counters and timing, and `ui_get_window_rect` / `ui_set_window_rect` / `ui_capture_window_snapshot` for resize-based editor UI QA. +- `batch_execute` runs requests serially, accepts at most 50 requests, and rejects nested `batch_execute` calls. MCP bridge: @@ -127,11 +124,16 @@ The current development line preserves backward compatibility while correcting p - `unity_write_and_compile` is the supported public MCP macro for write, wait, and compiler-feedback workflows. Any old documentation wording that says `unity_apply_code_change` should be treated as stale. - `update_component` should use a `properties` object for new callers; `json_data` remains accepted for existing callers. +- `unity_scene_manager` aliases such as `create_scene`, `open_scene`, `save_scene`, and `list_scenes` preserve action-specific required parameters. - `create_scene` accepts optional `path` and `open_if_exists` for deterministic scene assets. - `create_primitive` accepts optional `name`, `parent_id`, `position`, `rotation`, `scale`, and `material_path`. +- Vector3 fields accept either `{ "x": 0, "y": 1, "z": 0 }` or `[0, 1, 0]`. - `set_transform` accepts `position`, `rotation` / `eulerAngles`, and `scale` / `localScale`. +- `get_game_object` includes transform state and a compact component list for cheap write verification; use `inspect_component` for full component properties. +- Script writes require `confirm: true`, then readiness probes remain busy while the scheduled asset refresh is pending so agents do not race Unity domain reload. +- Play Mode transitions keep readiness probes busy until Unity finishes entering or exiting Play Mode. - `create_material` accepts an optional explicit `path` plus `base_color` / `color` and `emission_color`, so automation can isolate generated materials in a known project folder and make them visibly distinct. -- `write_file` and `write_files_batch` create missing parent directories after path validation. +- `write_file` and `write_files_batch` create missing parent directories after path validation; `.cs` writes require `confirm: true`. - `invoke_method.arguments` is documented and exposed as a JSON array schema. - `click_object_in_game` accepts either `instance_id` or hierarchy `path`. - Unity 6.x compatibility is maintained across editor object identity API changes. External JSON payloads continue to use integer-shaped `instance_id` values, while Nexus maps them to `EntityId` internally and falls back to Unity 6.3-compatible hierarchy event and object-reference instance ID APIs when Unity 6.4+ members are unavailable. @@ -139,7 +141,7 @@ The current development line preserves backward compatibility while correcting p ## Recommended AI Workflow -Use `unity_write_and_compile` for code edits. It writes one or more files, waits for Unity reload/import work, and returns compiler errors from the Unity Console. Use `unity_wait` when a workflow needs to block until compilation, import, play mode, or editor idle state is reached. +Use `unity_write_and_compile` with `confirm: true` for code edits. It writes one or more files, waits for Unity reload/import work, and returns compiler errors from the Unity Console. Use `unity_wait` when a workflow needs to block until compilation, import, play mode, or editor idle state is reached. For scene work, prefer manager tools: @@ -187,6 +189,8 @@ For a focused agent tooling check, run `python3 scripts/agent-tooling-smoke.py`. Before release, maintainers should run a public API stress audit that compares raw `list_tools` output with the MCP bridge catalog, exercises read-only and mutating tool groups in a disposable namespace, and verifies cleanup of generated assets and PlayerPrefs keys. +PlayerPrefs cleanup should use specific disposable keys. Bulk cleanup requires `key: "all"` plus `confirm: true` because Unity does not provide an Undo path for `PlayerPrefs.DeleteAll()`. + ## Troubleshooting - Server does not start: change the port in project settings and restart the server window. @@ -196,14 +200,14 @@ Before release, maintainers should run a public API stress audit that compares r - Compile loop times out: call `get_server_status`, `read_logs`, and `unity_wait` with `editor_idle`. - Auto Setup reports success but the client does not connect: confirm you used the card for the client you actually run (Claude Code reads `.mcp.json`, not the Claude Desktop config), then reload the client (`/mcp` in Claude Code) after starting the Nexus server. - Bridge fails to launch on Windows: verify a Python 3 interpreter is on `PATH` (`python --version` or `py --version`); the generated config uses `python3`, `python`, or `py` in that order. -- Gemini/Antigravity card shows `Not found` or disabled Auto Setup: the CLI is not on the Editor's `PATH`. Install it and ensure `where ` (Windows) or `which ` (macOS/Linux) resolves, or use `Copy Config`. +- Gemini card shows `Not found` or disabled Auto Setup: the CLI is not on the Editor's `PATH`. Install it and ensure `where gemini` (Windows) or `which gemini` (macOS/Linux) resolves, or use `Copy Config`. ## Packaging Rules - Public repo: `https://github.com/ForkHorizon/NexusUnity.git`. - Package id: `com.forkhorizon.nexus.unity`. -- Public release version: `1.4.2`. -- License: `GPL-3.0-only`. +- Public release version: `1.5.0`. +- License: `MIT`. - Required release docs: `SECURITY.md`, `CONTRIBUTING.md`, and `RELEASE.md`. - Repository funding metadata lives in `.github/FUNDING.yml` and configures the GitHub Sponsor button for `Daliys`. - Do not ship generated caches, local agent folders, `.jules/`, `.DS_Store`, Python bytecode, or native bridge binaries without corresponding source and build instructions. @@ -214,12 +218,12 @@ Before release, maintainers should run a public API stress audit that compares r Nexus Unity follows semantic versioning for public releases, but the development branch should not bump the package version for every merged fix. Keep `package.json` and visible docs at the latest shipped public version until a release is being prepared. -Unity Package Manager requires `MAJOR.MINOR.PATCH` values in `package.json`, and GitHub release tags and titles use the same semantic version. Use forms like `1.4.2` for the package version, `v1.4.2` for tags, and `1.4.2` for release titles. +Unity Package Manager requires `MAJOR.MINOR.PATCH` values in `package.json`, and GitHub release tags and titles use the same semantic version. Use forms like `1.5.0` for the package version, `v1.5.0` for tags, and `1.5.0` for release titles. During normal development: - Add all user-visible API, behavior, docs, and validation changes to `[Unreleased]` in `CHANGELOG.md`. -- Do not change `package.json` from `1.4.2` unless the change is part of a release-preparation commit. +- Do not change `package.json` from `1.5.0` unless the change is part of a release-preparation commit. - Prefer compatibility fixes over breaking changes; if a breaking change is unavoidable, document the migration path before release. During release preparation: @@ -227,4 +231,4 @@ During release preparation: - Choose the next semantic version based on accumulated changes. - Move `[Unreleased]` entries into the new dated release section. - Update `package.json`, README badges/install examples, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`. -- Tag the release with the matching semantic GitHub version, for example `v1.4.2` for package version `1.4.2`. +- Tag the release with the matching semantic GitHub version, for example `v1.5.0` for package version `1.5.0`. diff --git a/Editor/CodexLinkTester.cs b/Editor/CodexLinkTester.cs index 6582ace..42cb93e 100644 --- a/Editor/CodexLinkTester.cs +++ b/Editor/CodexLinkTester.cs @@ -5,16 +5,16 @@ namespace UnityMCP.Editor { /// Provides a maintainer-only diagnostic action that runs the Codex CLI linking flow from the Nexus Unity resources panel. /// /// - /// The action can modify the local Codex configuration on the developer machine by delegating to - /// , and writes progress to the Nexus diagnostics log category. + /// This is a thin troubleshooting wrapper: it logs start and finish messages to the Unity Console and delegates the actual + /// linking work — including any local Codex/MCP client configuration changes — to . /// public static class CodexLinkTester { /// - /// Runs the Codex integration link diagnostic and records start and finish messages in the Unity Console. + /// Runs the Codex integration link diagnostic, logging start and finish messages to the Unity Console. /// /// - /// This method is intended for manual troubleshooting from the advanced diagnostics UI and may update local MCP client - /// configuration files through the Codex installer helper. + /// Intended for manual troubleshooting from the advanced diagnostics UI. The actual linking and any local MCP client + /// configuration changes are performed by ; this method itself only logs and delegates. /// public static void TestLink() { NexusEditorLog.Log(NexusLogCategory.Diagnostics, "[Test] Starting Codex link test...", true); diff --git a/Editor/MCPCliInstaller.Anthropic.cs b/Editor/MCPCliInstaller.Anthropic.cs deleted file mode 100644 index 0f0c911..0000000 --- a/Editor/MCPCliInstaller.Anthropic.cs +++ /dev/null @@ -1,79 +0,0 @@ -using UnityEditor; -using UnityEngine; -using System.IO; -using System.Diagnostics; -using System.Collections.Generic; -using System; -using Newtonsoft.Json.Linq; - -namespace UnityMCP.Editor -{ - public static partial class MCPCliInstaller - { - /// - /// Links the current Unity project to Anthropic Claude Desktop by deploying the bridge script and rewriting the local Claude MCP config. - /// - /// - /// This editor action copies bridge files into the project root, resolves a Python executable, backs up any existing - /// Claude Desktop config, writes the nexus-unity server entry, logs progress, and shows success/error dialogs. - /// - public static void LinkToAnthropic() - { - if (DeployBridgeScript(out string destinationPath)) - { - string pythonPath = ResolvePythonPath(); - ExecuteAnthropicLinkSequence(destinationPath, pythonPath); - } - } - - private static void ExecuteAnthropicLinkSequence(string scriptPath, string pythonPath) - { - try - { - string configPath = ""; -#if UNITY_EDITOR_OSX - // On macOS, SpecialFolder.ApplicationData resolves to ~/.config (XDG) under Mono, but Claude Desktop - // reads ~/Library/Application Support/Claude. Build that path explicitly. - configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json"); -#elif UNITY_EDITOR_WIN - configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"); -#endif - - if (string.IsNullOrEmpty(configPath)) { - NexusEditorLog.Error(NexusLogCategory.Integrations, "[MCP] Unsupported platform for automatic Anthropic config linkage."); - return; - } - - JObject config; - if (File.Exists(configPath)) { - string content = File.ReadAllText(configPath); - try { config = JObject.Parse(content); } - catch { config = new JObject(); } - } else { - config = new JObject(); - string dir = Path.GetDirectoryName(configPath); - if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); - } - - if (config["mcpServers"] == null) config["mcpServers"] = new JObject(); - JObject servers = (JObject)config["mcpServers"]; - - servers["nexus-unity"] = new JObject { - ["command"] = pythonPath, - ["args"] = new JArray { scriptPath } - }; - - NexusMcpConfigGenerator.BackupFileIfExists(configPath); - File.WriteAllText(configPath, config.ToString(Newtonsoft.Json.Formatting.Indented)); - - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Unity project to Anthropic Claude Desktop.", true); - EditorUtility.DisplayDialog("Success", "Successfully configured Claude Desktop to use the Unity MCP Server.\n\nPlease restart Claude Desktop for the changes to take effect.", "OK"); - } - catch (Exception e) - { - NexusEditorLog.Error(NexusLogCategory.Integrations, $"[MCP] Failed to link Anthropic: {e.Message}"); - EditorUtility.DisplayDialog("Error", $"Failed to link to Anthropic Claude Desktop:\n{e.Message}", "OK"); - } - } - } -} diff --git a/Editor/MCPCliInstaller.Anthropic.cs.meta b/Editor/MCPCliInstaller.Anthropic.cs.meta deleted file mode 100644 index c638d1b..0000000 --- a/Editor/MCPCliInstaller.Anthropic.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ecb2612490ef742a2928b3b3cbc4d0f9 \ No newline at end of file diff --git a/Editor/MCPCliInstaller.Antigravity.cs b/Editor/MCPCliInstaller.Antigravity.cs deleted file mode 100644 index 0a66201..0000000 --- a/Editor/MCPCliInstaller.Antigravity.cs +++ /dev/null @@ -1,42 +0,0 @@ -using UnityEditor; -using UnityEngine; -using System.IO; -using System.Diagnostics; -using System.Collections.Generic; -using System; - -namespace UnityMCP.Editor -{ - public static partial class MCPCliInstaller - { - /// - /// Links the current Unity project to the local Antigravity CLI by deploying the bridge script and executing MCP remove/add commands. - /// - /// - /// This editor action modifies project-root bridge files, resolves python3 and Antigravity executables, - /// launches local CLI processes, and reports failures through Nexus editor logs or dialogs. - /// - public static void LinkToAntigravity() - { - if (DeployBridgeScript(out string destinationPath)) - { - string pythonPath = ResolvePythonPath(); - ExecuteAntigravityLinkSequence(destinationPath, pythonPath); - } - } - - private static void ExecuteAntigravityLinkSequence(string scriptPath, string pythonPath) - { - string agPath = ResolveExecutablePath("ag"); - if (string.IsNullOrEmpty(agPath)) agPath = ResolveExecutablePath("antigravity"); - - // 1. Ensure clean slate - string removeCommand = "\"" + agPath + "\" mcp remove nexus-unity"; - RunInstallerProcess(CreateProcessStartInfo(removeCommand), agPath, false, "Antigravity"); - - // 2. Add new registration - string addCommand = "\"" + agPath + "\" mcp add nexus-unity --command \"" + pythonPath + "\" --args \"" + scriptPath + "\""; - RunInstallerProcess(CreateProcessStartInfo(addCommand), agPath, true, "Antigravity"); - } - } -} diff --git a/Editor/MCPCliInstaller.Antigravity.cs.meta b/Editor/MCPCliInstaller.Antigravity.cs.meta deleted file mode 100644 index 0733d32..0000000 --- a/Editor/MCPCliInstaller.Antigravity.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8a58acd5013b049798b5e1e28cbccb15 \ No newline at end of file diff --git a/Editor/MCPCliInstaller.ClaudeCode.cs b/Editor/MCPCliInstaller.ClaudeCode.cs index 2116f6a..e7734f0 100644 --- a/Editor/MCPCliInstaller.ClaudeCode.cs +++ b/Editor/MCPCliInstaller.ClaudeCode.cs @@ -16,7 +16,8 @@ public static partial class MCPCliInstaller /// Prefers the official claude CLI (claude mcp add --scope project, which itself writes /// .mcp.json). When the CLI is unavailable or fails, it falls back to writing .mcp.json directly. /// Either path produces the same project-root .mcp.json that the Integrations tab tracks. This is distinct - /// from , which targets the separate Claude Desktop app config. + /// from the Claude Desktop app config, which is handled by the generic JSON-config codepath in + /// instead of a dedicated CLI installer. /// public static void LinkToClaudeCode() { @@ -34,20 +35,22 @@ private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pyth // Preferred path: the official claude CLI writes the project-scoped .mcp.json for us. if (!string.IsNullOrEmpty(claudePath) && claudePath != "claude") { - // 1. Remove any stale registration so re-running is idempotent (silent). - string removeCommand = "\"" + claudePath + "\" mcp remove nexus-unity -s project"; - RunInstallerProcess(CreateProcessStartInfo(removeCommand), claudePath, false, "Claude Code"); + // 1. Remove any stale registration so re-running is idempotent. + // A missing registration is expected on first setup and is safe to add over. + bool removedStaleRegistration = RunInstallerProcess(CreateProcessStartInfo(claudePath, "mcp", "remove", "--scope", "project", "nexus-unity"), claudePath, false, "Claude Code", out string removeError, false); + bool registrationWasAbsent = !removedStaleRegistration && IsClaudeCodeRegistrationAbsent(removeError); - // 2. Add the server at project scope (silent, because we have a file fallback). - string addCommand = "\"" + claudePath + "\" mcp add nexus-unity -s project -- \"" + pythonPath + "\" \"" + scriptPath + "\""; - if (RunInstallerProcess(CreateProcessStartInfo(addCommand), claudePath, false, "Claude Code")) + // 2. Add the server at project scope only when the prior state is known to be clear. + if ((removedStaleRegistration || registrationWasAbsent) && RunInstallerProcess(CreateProcessStartInfo(claudePath, "mcp", "add", "--transport", "stdio", "--scope", "project", "--env", MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken, "nexus-unity", "--", pythonPath, scriptPath), claudePath, false, "Claude Code")) { NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Claude Code via '" + claudePath + "'.", true); EditorUtility.DisplayDialog("MCP Success", "Successfully linked Nexus Unity to Claude Code.\n\nRun /mcp inside Claude Code (or restart it) to load the server.", "OK"); return; } - NexusEditorLog.Warning(NexusLogCategory.Integrations, "[MCP] Claude Code CLI command failed at '" + claudePath + "'. Falling back to direct .mcp.json edit."); + NexusEditorLog.Warning(NexusLogCategory.Integrations, (removedStaleRegistration || registrationWasAbsent) + ? "[MCP] Claude Code CLI add command failed at '" + claudePath + "'. Falling back to direct .mcp.json edit." + : "[MCP] Claude Code CLI could not remove the existing registration at '" + claudePath + "': " + removeError + ". Skipping CLI add and falling back to direct .mcp.json edit."); } // Fallback: write the project-root .mcp.json directly. @@ -73,7 +76,8 @@ private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pyth servers["nexus-unity"] = new JObject { ["command"] = pythonPath.Replace("\\", "/"), - ["args"] = new JArray { scriptPath.Replace("\\", "/") } + ["args"] = new JArray { scriptPath.Replace("\\", "/") }, + ["env"] = new JObject { [MCPServer.AuthTokenEnvironmentVariable] = MCPServer.AuthToken } }; NexusMcpConfigGenerator.BackupFileIfExists(configPath); @@ -88,5 +92,12 @@ private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pyth EditorUtility.DisplayDialog("MCP Error", "Failed to write .mcp.json for Claude Code.\n\n" + e.Message, "OK"); } } + + internal static bool IsClaudeCodeRegistrationAbsent(string error) + { + return !string.IsNullOrEmpty(error) + && error.IndexOf("nexus-unity", StringComparison.OrdinalIgnoreCase) >= 0 + && error.IndexOf("not found", StringComparison.OrdinalIgnoreCase) >= 0; + } } } diff --git a/Editor/MCPCliInstaller.Codex.cs b/Editor/MCPCliInstaller.Codex.cs index aba4700..709bdbd 100644 --- a/Editor/MCPCliInstaller.Codex.cs +++ b/Editor/MCPCliInstaller.Codex.cs @@ -1,8 +1,7 @@ using UnityEditor; using UnityEngine; using System.IO; -using System.Diagnostics; -using System.Collections.Generic; +using System.Linq; using System; namespace UnityMCP.Editor @@ -33,14 +32,11 @@ private static void ExecuteCodexLinkSequence(string scriptPath, string pythonPat if (!string.IsNullOrEmpty(codexPath) && codexPath != "codex") { // 1. Remove existing to ensure clean slate (silent) - string removeCommand = "\"" + codexPath + "\" mcp remove nexus-unity"; - RunInstallerProcess(CreateProcessStartInfo(removeCommand), codexPath, false, "Codex"); + RunInstallerProcess(CreateProcessStartInfo(codexPath, "mcp", "remove", "nexus-unity"), codexPath, false, "Codex"); // 2. Add new with command/args (silent, because we have a fallback) - string addCommand = "\"" + codexPath + "\" mcp add nexus-unity -- \"" + pythonPath + "\" \"" + scriptPath + "\""; - // If it succeeds, we are done - if (RunInstallerProcess(CreateProcessStartInfo(addCommand), codexPath, false, "Codex")) + if (RunInstallerProcess(CreateProcessStartInfo(codexPath, "mcp", "add", "nexus-unity", "--env", MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken, "--", pythonPath, scriptPath), codexPath, false, "Codex")) { NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Codex CLI via '" + codexPath + "'", true); EditorUtility.DisplayDialog("MCP Success", "Successfully linked Nexus Unity to your system Codex CLI!", "OK"); @@ -55,74 +51,12 @@ private static void ExecuteCodexLinkSequence(string scriptPath, string pythonPat try { string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string codexDir = Path.Combine(homeDir, ".codex"); - string configPath = Path.Combine(codexDir, "config.toml"); - - if (!Directory.Exists(codexDir)) - { - Directory.CreateDirectory(codexDir); - } - - List lines = new List(); - if (File.Exists(configPath)) - { - lines.AddRange(File.ReadAllLines(configPath)); - } - - string safeScriptPath = scriptPath.Replace("\\", "/"); - string safePythonPath = pythonPath.Replace("\\", "/"); - - // Check if [mcp_servers.nexus-unity] already exists - int existingIndex = -1; - for (int i = 0; i < lines.Count; i++) - { - if (lines[i].Trim() == "[mcp_servers.nexus-unity]") - { - existingIndex = i; - break; - } - } - - if (existingIndex != -1) - { - // Update existing - bool foundCommand = false; - bool foundArgs = false; - for (int i = existingIndex + 1; i < lines.Count; i++) - { - string trimmed = lines[i].Trim(); - if (trimmed.StartsWith("[")) break; // Next section - - if (trimmed.StartsWith("command")) - { - lines[i] = "command = \"" + safePythonPath + "\""; - foundCommand = true; - } - else if (trimmed.StartsWith("args")) - { - lines[i] = "args = [ \"" + safeScriptPath + "\" ]"; - foundArgs = true; - } - } - - if (!foundCommand) lines.Insert(existingIndex + 1, "command = \"" + safePythonPath + "\""); - if (!foundArgs) lines.Insert(existingIndex + (foundCommand ? 2 : 1), "args = [ \"" + safeScriptPath + "\" ]"); - } - else - { - // Append new - if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[lines.Count - 1])) - { - lines.Add(""); // Add a blank line for spacing - } - lines.Add("[mcp_servers.nexus-unity]"); - lines.Add("command = \"" + safePythonPath + "\""); - lines.Add("args = [ \"" + safeScriptPath + "\" ]"); - } - - NexusMcpConfigGenerator.BackupFileIfExists(configPath); - File.WriteAllLines(configPath, lines); - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Codex CLI via manual TOML update: " + configPath, true); + string projectRoot = Path.GetDirectoryName(Application.dataPath); + var codex = NexusMcpConfigGenerator.BuildAll(scriptPath, pythonPath, projectRoot, homeDir) + .First(client => client.Kind == NexusMcpClientKind.Codex); + var result = NexusMcpConfigGenerator.WriteConfig(codex); + if (!result.Success) throw new Exception(result.Message); + NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Codex CLI via manual TOML update: " + result.ConfigPath, true); EditorUtility.DisplayDialog("MCP Success", "Successfully linked Nexus Unity to your system Codex CLI!", "OK"); } catch (Exception e) diff --git a/Editor/MCPCliInstaller.Gemini.cs b/Editor/MCPCliInstaller.Gemini.cs index 490a469..41e7b5f 100644 --- a/Editor/MCPCliInstaller.Gemini.cs +++ b/Editor/MCPCliInstaller.Gemini.cs @@ -29,13 +29,11 @@ private static void ExecuteGeminiLinkSequence(string scriptPath) string geminiPath = ResolveExecutablePath("gemini"); string pythonPath = ResolvePythonPath(); - // 1. Ensure clean slate by removing existing registration - string removeCommand = "\"" + geminiPath + "\" mcp remove nexus-unity"; - RunInstallerProcess(CreateProcessStartInfo(removeCommand), geminiPath, false, "Gemini"); + // 1. Ensure clean slate by removing the existing project registration. + RunInstallerProcess(CreateProcessStartInfo(geminiPath, "mcp", "remove", "--scope", "project", "nexus-unity"), geminiPath, false, "Gemini"); - // 2. Add new registration with stable path - string addCommand = "\"" + geminiPath + "\" mcp add nexus-unity --trust \"" + pythonPath + "\" \"" + scriptPath + "\""; - RunInstallerProcess(CreateProcessStartInfo(addCommand), geminiPath, true, "Gemini"); + // 2. Add a stdio registration without bypassing Gemini's tool-approval prompts. + RunInstallerProcess(CreateProcessStartInfo(geminiPath, "mcp", "add", "--scope", "project", "--transport", "stdio", "--env", MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken, "nexus-unity", pythonPath, scriptPath), geminiPath, true, "Gemini"); } } } diff --git a/Editor/MCPCliInstaller.cs b/Editor/MCPCliInstaller.cs index 6708781..47c3e07 100644 --- a/Editor/MCPCliInstaller.cs +++ b/Editor/MCPCliInstaller.cs @@ -2,8 +2,8 @@ using UnityEngine; using System.IO; using System.Diagnostics; -using System.Collections.Generic; using System; +using System.Text; namespace UnityMCP.Editor { @@ -231,8 +231,12 @@ private static string GetPathFromWhich(string name) { string pathEnv = ""; try { pathEnv = Environment.GetEnvironmentVariable("PATH"); } catch(Exception) {} - // Don't inject Homebrew here, let it find what's in the actual PATH - psi.EnvironmentVariables["PATH"] = pathEnv; + // Unity launched from Finder/Hub (not a terminal) inherits a stripped PATH + // (typically just /usr/bin:/bin:/usr/sbin:/sbin) with no /usr/local/bin or /opt/homebrew/bin. + // 'which' would then resolve python3 to the old Xcode-bundled interpreter at /usr/bin/python3 + // instead of a modern one. Prepend common interpreter locations so they're checked first, + // matching the priority order ResolveExecutablePath's own fallback search list already uses. + psi.EnvironmentVariables["PATH"] = "/usr/local/bin:/opt/homebrew/bin:" + pathEnv; } try @@ -302,13 +306,15 @@ private static string ResolvePythonPath() return "python3"; } - private static ProcessStartInfo CreateProcessStartInfo(string command) + private static ProcessStartInfo CreateProcessStartInfo(string executable, params string[] arguments) { bool isWindows = Application.platform == RuntimePlatform.WindowsEditor; + string extension = Path.GetExtension(executable); + bool useCmdShim = isWindows && (string.Equals(extension, ".cmd", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".bat", StringComparison.OrdinalIgnoreCase)); ProcessStartInfo psi = new ProcessStartInfo { - FileName = isWindows ? "cmd.exe" : "/bin/bash", - Arguments = isWindows ? ("/c \"" + command + "\"") : ("-c \"" + command + "\""), + FileName = useCmdShim ? "cmd.exe" : executable, + Arguments = useCmdShim ? BuildWindowsBatchArguments(executable, arguments) : BuildProcessArguments(arguments), RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true @@ -325,13 +331,83 @@ private static ProcessStartInfo CreateProcessStartInfo(string command) return psi; } + private static string BuildProcessArguments(string[] arguments) + { + return string.Join(" ", Array.ConvertAll(arguments, QuoteWindowsArgument)); + } + + private static string BuildWindowsBatchArguments(string executable, string[] arguments) + { + string command = QuoteCmdArgument(executable); + foreach (string argument in arguments) + { + command += " " + QuoteCmdArgument(argument); + } + return "/d /v:off /s /c " + QuoteWindowsArgument(command); + } + + private static string QuoteCmdArgument(string argument) + { + return QuoteWindowsArgument(EscapeCmdMetacharacters(argument)); + } + + private static string EscapeCmdMetacharacters(string value) + { + if (string.IsNullOrEmpty(value)) return value; + return value + .Replace("^", "^^") + .Replace("&", "^&") + .Replace("|", "^|") + .Replace("<", "^<") + .Replace(">", "^>") + .Replace("%", "^%"); + } + + private static string QuoteWindowsArgument(string argument) + { + if (string.IsNullOrEmpty(argument)) return "\"\""; + if (argument.IndexOfAny(new[] { ' ', '\t', '\n', '\r', '"' }) < 0) return argument; + + StringBuilder result = new StringBuilder(); + result.Append('"'); + int backslashes = 0; + foreach (char c in argument) + { + if (c == '\\') + { + backslashes++; + continue; + } + + if (c == '"') + { + result.Append('\\', backslashes * 2 + 1); + result.Append('"'); + backslashes = 0; + continue; + } + result.Append('\\', backslashes); + result.Append(c); + backslashes = 0; + } + result.Append('\\', backslashes * 2); + result.Append('"'); + return result.ToString(); + } + private static bool RunInstallerProcess(ProcessStartInfo psi, string cliPath, bool showSuccessDialog, string cliName) { + return RunInstallerProcess(psi, cliPath, showSuccessDialog, cliName, out _, true); + } + + private static bool RunInstallerProcess(ProcessStartInfo psi, string cliPath, bool showSuccessDialog, string cliName, out string error, bool logFailure) + { + error = string.Empty; try { using (Process p = Process.Start(psi)) { - string error = p.StandardError.ReadToEnd(); + error = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode == 0) @@ -341,7 +417,7 @@ private static bool RunInstallerProcess(ProcessStartInfo psi, string cliPath, bo else { string msg = "CLI command failed at " + cliPath + ".\n\nExit Code: " + p.ExitCode + "\nError: " + error; - NexusEditorLog.Warning(NexusLogCategory.Integrations, "[MCP] " + msg); + if (logFailure) NexusEditorLog.Warning(NexusLogCategory.Integrations, "[MCP] " + msg); if (showSuccessDialog) // If this was supposed to be the final step { @@ -352,6 +428,7 @@ private static bool RunInstallerProcess(ProcessStartInfo psi, string cliPath, bo } catch (Exception e) { + error = e.Message; NexusEditorLog.Error(NexusLogCategory.Integrations, "[MCP] Process start failed: " + e.Message); } return false; diff --git a/Editor/MCPServer.Logs.cs b/Editor/MCPServer.Logs.cs index 46acafe..f2d263c 100644 --- a/Editor/MCPServer.Logs.cs +++ b/Editor/MCPServer.Logs.cs @@ -108,12 +108,7 @@ private static void SyncWithUnityConsole() internal static void HandleMainThreadQueue() { - LastMainThreadTickUtc = DateTime.UtcNow; - IsCompilingCached = EditorApplication.isCompiling; - IsUpdatingCached = EditorApplication.isUpdating; - IsPlayingCached = EditorApplication.isPlaying; - IsPausedCached = EditorApplication.isPaused; - IsPlayModeTransitionCached = EditorApplication.isPlayingOrWillChangePlaymode; + RefreshMainThreadCachedState(); if (_mainThreadQueue == null || _mainThreadQueue.IsEmpty) return; diff --git a/Editor/MCPServer.Networking.cs b/Editor/MCPServer.Networking.cs index bc3aaed..fd3acc0 100644 --- a/Editor/MCPServer.Networking.cs +++ b/Editor/MCPServer.Networking.cs @@ -17,10 +17,12 @@ private static async Task IsAnotherMcpInstanceRunning() { using var client = new System.Net.Http.HttpClient(); client.Timeout = TimeSpan.FromMilliseconds(500); + client.DefaultRequestHeaders.Add(AuthTokenHeaderName, AuthToken); try { var content = new System.Net.Http.StringContent("{\"jsonrpc\":\"2.0\",\"method\":\"get_server_status\",\"params\":{},\"id\":1}", Encoding.UTF8, "application/json"); var response = await client.PostAsync($"http://127.0.0.1:{_port}/", content); + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) return true; string body = await response.Content.ReadAsStringAsync(); if (body.Contains("serverAlive")) @@ -58,6 +60,7 @@ private static async Task IsAnotherMcpInstanceRunning() // Fallback to old initialize method just in case it's an older server var initContent = new System.Net.Http.StringContent("{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}", Encoding.UTF8, "application/json"); var initResponse = await client.PostAsync($"http://127.0.0.1:{_port}/", initContent); + if (initResponse.StatusCode == HttpStatusCode.Unauthorized || initResponse.StatusCode == HttpStatusCode.Forbidden) return true; string initBody = await initResponse.Content.ReadAsStringAsync(); if (initBody.Contains("Unity MCP Server")) { NexusEditorLog.Log(NexusLogCategory.Server, $"[MCP] Connected to an older existing session on port {_port}", true); @@ -139,6 +142,12 @@ private static async Task ProcessWebSocket(HttpListenerContext context) return; } + if (!IsAuthorized(context)) + { + RejectUnauthorized(context); + return; + } + var wsContext = await context.AcceptWebSocketAsync(null); _webSocket = wsContext.WebSocket; await ReceiveWebsocketLoop(_cts.Token); @@ -154,6 +163,12 @@ private static void HandleHttpRequest(HttpListenerContext context) return; } + if (!IsAuthorized(context)) + { + RejectUnauthorized(context); + return; + } + if (context.Request.HttpMethod != "POST") { context.Response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; context.Response.Close(); @@ -205,6 +220,22 @@ private static void HandleHttpRequest(HttpListenerContext context) } } + private static bool IsAuthorized(HttpListenerContext context) + { + return IsAuthorizedToken(context.Request.Headers[AuthTokenHeaderName]); + } + + internal static bool IsAuthorizedToken(string token) + { + return !string.IsNullOrEmpty(token) && string.Equals(token, _authToken, StringComparison.Ordinal); + } + + private static void RejectUnauthorized(HttpListenerContext context) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + context.Response.Close(); + } + private static async Task ReceiveWebsocketLoop(CancellationToken token) { var buffer = new byte[4096]; diff --git a/Editor/MCPServer.cs b/Editor/MCPServer.cs index 79ad8b9..86e9b47 100644 --- a/Editor/MCPServer.cs +++ b/Editor/MCPServer.cs @@ -31,12 +31,16 @@ public static partial class MCPServer public static string SessionId { get; private set; } public static int SessionGeneration { get; private set; } + internal const string AuthTokenEnvironmentVariable = "NEXUS_UNITY_AUTH_TOKEN"; + internal const string AuthTokenHeaderName = "X-Nexus-Unity-Token"; + private static string _authToken; public static DateTime LastMainThreadTickUtc { get; private set; } public static bool IsCompilingCached { get; private set; } public static bool IsUpdatingCached { get; private set; } public static bool IsPlayingCached { get; private set; } public static bool IsPausedCached { get; private set; } public static bool IsPlayModeTransitionCached { get; private set; } + public static string UnityVersionCached { get; private set; } private static ConcurrentQueue _mainThreadQueue; private static ConcurrentQueue _logs; @@ -60,18 +64,46 @@ private static string GetDeterministicProjectKey() } private static string _prefsKeyCached; private static string StablePrefsKey => _prefsKeyCached ?? (_prefsKeyCached = GetDeterministicProjectKey()); + private static string AuthSessionStateKey => StablePrefsKey + "_AuthToken"; private static int _mainThreadId = -1; public static int MainThreadId => _mainThreadId; private static readonly object _startLock = new object(); + internal static void RefreshMainThreadCachedState() + { + LastMainThreadTickUtc = DateTime.UtcNow; + IsCompilingCached = EditorApplication.isCompiling; + IsUpdatingCached = EditorApplication.isUpdating; + IsPlayingCached = EditorApplication.isPlaying; + IsPausedCached = EditorApplication.isPaused; + IsPlayModeTransitionCached = EditorApplication.isPlayingOrWillChangePlaymode; + UnityVersionCached = Application.unityVersion; + } + static MCPServer() { _mainThreadQueue = new ConcurrentQueue(); _logs = new ConcurrentQueue(); } + internal static string AuthToken => EnsureAuthToken(); + + private static string EnsureAuthToken() + { + if (!string.IsNullOrEmpty(_authToken)) return _authToken; + + _authToken = SessionState.GetString(AuthSessionStateKey, string.Empty); + if (string.IsNullOrEmpty(_authToken)) + { + _authToken = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + SessionState.SetString(AuthSessionStateKey, _authToken); + } + + return _authToken; + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void RuntimeInit() => Init(); @@ -88,9 +120,10 @@ internal static void Init() SessionState.SetString("MCP_SessionId", SessionId); SessionGeneration = SessionState.GetInt("MCP_SessionGen", 0) + 1; SessionState.SetInt("MCP_SessionGen", SessionGeneration); + EnsureAuthToken(); } - LastMainThreadTickUtc = DateTime.UtcNow; + RefreshMainThreadCachedState(); #if UNITY_EDITOR_OSX AppNapBypass.CacheApplicationPath(); #endif @@ -176,6 +209,7 @@ public static void Start() } MCPServerMethods.Init(); + EnsureAuthToken(); if (EditorApplication.isPlaying) Application.runInBackground = true; if (_port <= 0) { diff --git a/Editor/MCPServerMethods.Asset.cs b/Editor/MCPServerMethods.Asset.cs index 6b1fbaf..a5cf9e1 100644 --- a/Editor/MCPServerMethods.Asset.cs +++ b/Editor/MCPServerMethods.Asset.cs @@ -164,6 +164,11 @@ private static Color ParseColorToken(JToken token) private static JToken RefreshAssetDatabase(JToken p) { + if (DateTime.UtcNow < _scriptRefreshBusyUntilUtc) + { + return new JObject { ["status"] = "Compiling", ["is_compiling"] = false, ["is_updating"] = true, ["compiler_errors"] = new JArray() }; + } + #if UNITY_EDITOR_OSX // Signal AppNapBypass to bring Unity to the foreground so compilation can happen. AppNapBypass.ScheduleActivation(); diff --git a/Editor/MCPServerMethods.Component.cs b/Editor/MCPServerMethods.Component.cs index d305e09..a91fad8 100644 --- a/Editor/MCPServerMethods.Component.cs +++ b/Editor/MCPServerMethods.Component.cs @@ -33,6 +33,7 @@ private static JToken AddComponent(JToken p) { if (p == null || p["instance_id"] == null || p["component_name"] == null) throw new Exception("instance_id and component_name required"); var go = MCPServerMethods.IdToObject(MCPServerMethods.ExtractId(p)) as GameObject; + if (go == null) throw new Exception("GameObject not found"); Type type = FindType(p["component_name"].ToString()); if (type == null) throw new Exception($"Type '{p["component_name"]}' not found"); return new JObject { ["status"] = "Success", ["message"] = $"Added {p["component_name"]} to {Undo.AddComponent(go, type).name}" }; diff --git a/Editor/MCPServerMethods.Core.cs b/Editor/MCPServerMethods.Core.cs index e3842f6..783e97c 100644 --- a/Editor/MCPServerMethods.Core.cs +++ b/Editor/MCPServerMethods.Core.cs @@ -17,6 +17,9 @@ namespace UnityMCP.Editor /// public static partial class MCPServerMethods { + private const int MaxBatchExecuteRequests = 50; + private static int _batchExecuteDepth; + private static void RegisterCoreMethods() { _methods["initialize"] = Initialize; @@ -41,20 +44,30 @@ private static JToken BatchExecute(JToken p) if (p == null || p["requests"] == null) throw new Exception("requests array is required"); var requests = p["requests"] as JArray; if (requests == null) throw new Exception("requests must be a JSON array"); + if (requests.Count > MaxBatchExecuteRequests) throw new Exception($"batch_execute supports at most {MaxBatchExecuteRequests} requests"); + if (_batchExecuteDepth > 0) throw new Exception("Recursive batch_execute is not allowed"); var results = new JArray(); - foreach (var req in requests) + _batchExecuteDepth++; + try { - string method = req["method"]?.ToString(); - if (method == "batch_execute") + foreach (var req in requests) { - results.Add(new JObject { ["status"] = "Error", ["message"] = "Recursive batch_execute is not allowed" }); - continue; + string method = req["method"]?.ToString(); + if (method == "batch_execute") + { + results.Add(new JObject { ["status"] = "Error", ["message"] = "Recursive batch_execute is not allowed" }); + continue; + } + + JToken par = req["params"]; + try { results.Add(ExecuteMethod(method, par)); } + catch (Exception e) { results.Add(new JObject { ["status"] = "Error", ["message"] = e.Message }); } } - - JToken par = req["params"]; - try { results.Add(ExecuteMethod(method, par)); } - catch (Exception e) { results.Add(new JObject { ["status"] = "Error", ["message"] = e.Message }); } + } + finally + { + _batchExecuteDepth--; } return new JObject { ["results"] = results }; } @@ -63,41 +76,61 @@ private static JToken CreatePrimitive(JToken p) { if (p == null || p["primitive_type"] == null) throw new Exception("primitive_type is required"); if (!Enum.TryParse(typeof(PrimitiveType), p["primitive_type"].ToString(), true, out var type)) throw new Exception("Invalid primitive"); - var go = GameObject.CreatePrimitive((PrimitiveType)type); - Undo.RegisterCreatedObjectUndo(go, "Create Primitive"); string name = p["name"]?.ToString(); - if (!string.IsNullOrWhiteSpace(name)) go.name = name; - + Transform parentTransform = null; if (p["parent_id"] != null) { var parent = MCPServerMethods.IdToObject(MCPServerMethods.ExtractId(p, "parent_id")) as GameObject; if (parent == null) throw new Exception("parent_id does not resolve to a GameObject"); - go.transform.SetParent(parent.transform, false); + parentTransform = parent.transform; } - if (p["position"] != null) go.transform.position = ParseVector3(p["position"], go.transform.position); - JToken rotation = p["rotation"] ?? p["eulerAngles"]; - if (rotation != null) go.transform.eulerAngles = ParseVector3(rotation, go.transform.eulerAngles); - JToken scale = p["scale"] ?? p["localScale"]; - if (scale != null) go.transform.localScale = ParseVector3(scale, go.transform.localScale); + Vector3? position = p["position"] != null ? ParseVector3(p["position"]) : null; + JToken rotationToken = p["rotation"] ?? p["eulerAngles"]; + Vector3? rotation = rotationToken != null ? ParseVector3(rotationToken) : null; + JToken scaleToken = p["scale"] ?? p["localScale"]; + Vector3? scale = scaleToken != null ? ParseVector3(scaleToken) : null; + Material material = null; string materialPath = p["material_path"]?.ToString(); if (!string.IsNullOrWhiteSpace(materialPath)) { materialPath = ValidateAssetPath(materialPath); - var material = AssetDatabase.LoadAssetAtPath(materialPath); + material = AssetDatabase.LoadAssetAtPath(materialPath); if (material == null) throw new Exception($"Material not found at {materialPath}"); - var renderer = go.GetComponent(); - if (renderer != null) renderer.sharedMaterial = material; } - Selection.activeGameObject = go; - return new JObject { ["status"] = "Success", ["data"] = SerializeGameObject(go) }; + GameObject go = null; + try + { + go = GameObject.CreatePrimitive((PrimitiveType)type); + Undo.RegisterCreatedObjectUndo(go, "Create Primitive"); + + if (!string.IsNullOrWhiteSpace(name)) go.name = name; + + if (parentTransform != null) go.transform.SetParent(parentTransform, false); + + if (position.HasValue) go.transform.position = position.Value; + if (rotation.HasValue) go.transform.eulerAngles = rotation.Value; + if (scale.HasValue) go.transform.localScale = scale.Value; + + var renderer = go.GetComponent(); + if (renderer != null && material != null) renderer.sharedMaterial = material; + + Selection.activeGameObject = go; + return new JObject { ["status"] = "Success", ["data"] = SerializeGameObject(go) }; + } + catch + { + if (go != null) UnityEngine.Object.DestroyImmediate(go); + throw; + } } private static JToken AttachScript(JToken p) { + RequireScriptWriteConfirmation(p); string name = SanitizeScriptName(p["script_name"].ToString()); string content = p["script_content"]?.ToString().Replace("\n", "\n") ?? GetDefaultScript(name); File.WriteAllText(Path.Combine("Assets", $"{name}.cs"), content); diff --git a/Editor/MCPServerMethods.Hierarchy.cs b/Editor/MCPServerMethods.Hierarchy.cs index 0e27213..5819930 100644 --- a/Editor/MCPServerMethods.Hierarchy.cs +++ b/Editor/MCPServerMethods.Hierarchy.cs @@ -17,6 +17,8 @@ namespace UnityMCP.Editor /// public static partial class MCPServerMethods { + private static DateTime _scriptRefreshBusyUntilUtc; + private static void RegisterHierarchyMethods() { _methods["duplicate_object"] = DuplicateObject; @@ -222,6 +224,9 @@ private static JToken WriteFile(JToken p) { if (p?["path"] == null || p["content"] == null) throw new System.Exception("path and content required"); string fullPath = ValidatePath(p["path"].ToString()); + bool isScript = IsCSharpScriptPath(fullPath); + if (isScript) RequireScriptWriteConfirmation(p); + EnsureParentDirectory(fullPath); System.IO.File.WriteAllText(fullPath, p["content"].ToString()); @@ -229,8 +234,6 @@ private static JToken WriteFile(JToken p) string root = System.IO.Path.GetFullPath("."); string relativePath = fullPath.Substring(root.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar).Replace("\\", "/"); - bool isScript = fullPath.EndsWith(".cs", System.StringComparison.OrdinalIgnoreCase); - if (!isScript) { // Non-scripts don't trigger domain reload, safe to run synchronously @@ -253,6 +256,18 @@ private static JToken WriteFilesBatch(JToken p) bool hasScripts = false; string root = System.IO.Path.GetFullPath("."); + foreach (var f in files) + { + if (f["path"] == null || f["content"] == null) continue; + if (IsCSharpScriptPath(ValidatePath(f["path"].ToString()))) + { + hasScripts = true; + break; + } + } + + if (hasScripts) RequireScriptWriteConfirmation(p); + foreach (var f in files) { if (f["path"] == null || f["content"] == null) continue; @@ -262,7 +277,7 @@ private static JToken WriteFilesBatch(JToken p) string relativePath = fullPath.Substring(root.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar).Replace("\\", "/"); - if (fullPath.EndsWith(".cs", System.StringComparison.OrdinalIgnoreCase)) + if (IsCSharpScriptPath(fullPath)) { hasScripts = true; } @@ -288,6 +303,8 @@ private static void EnsureParentDirectory(string fullPath) private static void TriggerSafeAssetRefresh() { + _scriptRefreshBusyUntilUtc = DateTime.UtcNow.AddSeconds(8); + // Scripts trigger domain reload which blocks the HTTP response. // Unity strictly aborts Domain Reloads if the Editor window is in the background // to protect external IDE development. We use LaunchServices (open -a) to explicitly diff --git a/Editor/MCPServerMethods.PlayerPrefs.cs b/Editor/MCPServerMethods.PlayerPrefs.cs index 9bdff33..18929ba 100644 --- a/Editor/MCPServerMethods.PlayerPrefs.cs +++ b/Editor/MCPServerMethods.PlayerPrefs.cs @@ -45,15 +45,25 @@ private static JToken SetPlayerPref(JToken p) private static JToken DeletePlayerPref(JToken p) { - string key = p["key"]?.ToString(); - if (string.IsNullOrEmpty(key) || key == "all") + string key = p?["key"]?.ToString(); + if (string.IsNullOrEmpty(key)) { - PlayerPrefs.DeleteAll(); + throw new ArgumentException("key is required"); } - else + + if (key == "all") { - PlayerPrefs.DeleteKey(key); + if (p?["confirm"]?.Value() != true) + { + throw new ArgumentException("Deleting all PlayerPrefs requires key: \"all\" and confirm: true because it is not undoable."); + } + + PlayerPrefs.DeleteAll(); + PlayerPrefs.Save(); + return new JObject { ["status"] = "Success", ["message"] = "Deleted all PlayerPrefs. This operation is not undoable." }; } + + PlayerPrefs.DeleteKey(key); PlayerPrefs.Save(); return "Success"; } diff --git a/Editor/MCPServerMethods.Reflection.cs b/Editor/MCPServerMethods.Reflection.cs index e098671..c297a0d 100644 --- a/Editor/MCPServerMethods.Reflection.cs +++ b/Editor/MCPServerMethods.Reflection.cs @@ -429,13 +429,21 @@ private static JToken FormatResult(MethodInfo method, object result) private static Vector3 ParseVector3(JToken t, Vector3 _defaultValue = default) { if (t == null) return _defaultValue; + if (t is JArray array) + { + if (array.Count != 3) throw new Exception("Vector3 array must have exactly 3 numbers"); + return new Vector3(array[0].Value(), array[1].Value(), array[2].Value()); + } + if (t.Type != JTokenType.Object) throw new Exception("Vector3 must be an object with x/y/z or an array [x,y,z]"); return new Vector3((float)(t["x"] ?? _defaultValue.x), (float)(t["y"] ?? _defaultValue.y), (float)(t["z"] ?? _defaultValue.z)); } private static JToken SerializeGameObject(GameObject go) { if (go == null) return JValue.CreateNull(); - return new JObject { ["name"] = go.name, ["instance_id"] = go.GetRawId() }; + return new JObject { ["name"] = go.name, ["instance_id"] = go.GetRawId(), + ["transform"] = new JObject { ["position"] = SerializeVector3(go.transform.position), ["rotation"] = SerializeVector3(go.transform.eulerAngles), ["scale"] = SerializeVector3(go.transform.localScale) }, + ["components"] = new JArray(go.GetComponents().Where(c => c != null).Select(c => SerializeComponentSnapshot(c, false))) }; } } } diff --git a/Editor/MCPServerMethods.Status.cs b/Editor/MCPServerMethods.Status.cs index 9ff5831..98544f9 100644 --- a/Editor/MCPServerMethods.Status.cs +++ b/Editor/MCPServerMethods.Status.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Collections.Generic; using UnityEditor; -using UnityEngine; using Newtonsoft.Json.Linq; namespace UnityMCP.Editor @@ -18,7 +17,12 @@ private static JToken ShutdownServer(JToken p) return new JObject { ["status"] = "Shutting down..." }; } - private static JToken Initialize(JToken p) => new JObject { ["protocolVersion"] = "2024-11-05", ["serverInfo"] = new JObject { ["name"] = "Unity MCP Server", ["version"] = MCPServer.Version } }; + private static JToken Initialize(JToken p) + { + if (MCPServer.IsCompilingCached || MCPServer.IsUpdatingCached || MCPServer.IsPlayModeTransitionCached || DateTime.UtcNow < _scriptRefreshBusyUntilUtc) + throw new Exception("Unity editor is busy compiling, importing assets, or changing play mode."); + return new JObject { ["protocolVersion"] = "2024-11-05", ["serverInfo"] = new JObject { ["name"] = "Unity MCP Server", ["version"] = MCPServer.Version } }; + } private static JToken GetServerStatus(JToken p) { @@ -27,12 +31,13 @@ private static JToken GetServerStatus(JToken p) bool isPlaying = MCPServer.IsPlayingCached; bool isPaused = MCPServer.IsPausedCached; bool isPlayModeTransition = MCPServer.IsPlayModeTransitionCached; + bool isScriptRefreshPending = DateTime.UtcNow < _scriptRefreshBusyUntilUtc; bool isMainThreadResponsive = (DateTime.UtcNow - MCPServer.LastMainThreadTickUtc).TotalSeconds < 5; string busyReason = "idle"; if (isCompiling) busyReason = "compiling"; - else if (isUpdating) busyReason = "importing"; - else if (isPlayModeTransition && !isPlaying) busyReason = "play_mode_transition"; + else if (isUpdating || isScriptRefreshPending) busyReason = "importing"; + else if (isPlayModeTransition) busyReason = "play_mode_transition"; return new JObject { ["serverAlive"] = MCPServer.IsRunning, @@ -43,19 +48,19 @@ private static JToken GetServerStatus(JToken p) ["sessionId"] = MCPServer.SessionId, ["processId"] = System.Diagnostics.Process.GetCurrentProcess().Id, ["projectPath"] = Directory.GetCurrentDirectory().Replace("\\", "/"), - ["unityVersion"] = Application.unityVersion, + ["unityVersion"] = MCPServer.UnityVersionCached, ["editorConnected"] = true, ["mainThreadResponsive"] = isMainThreadResponsive, ["editorState"] = new JObject { ["isPlaying"] = isPlaying, ["isCompiling"] = isCompiling, - ["isImporting"] = isUpdating, + ["isImporting"] = isUpdating || isScriptRefreshPending, ["isPaused"] = isPaused, ["isPlayModeTransition"] = isPlayModeTransition }, ["commandState"] = new JObject { ["acceptsReadCommands"] = true, - ["acceptsWriteCommands"] = !isCompiling && !isUpdating, + ["acceptsWriteCommands"] = !isCompiling && !isUpdating && !isPlayModeTransition && !isScriptRefreshPending, ["busyReason"] = busyReason }, ["lastHeartbeatUtc"] = MCPServer.LastMainThreadTickUtc.ToString("o"), diff --git a/Editor/MCPServerMethods.TestResults.cs b/Editor/MCPServerMethods.TestResults.cs index c42aa42..c4911e7 100644 --- a/Editor/MCPServerMethods.TestResults.cs +++ b/Editor/MCPServerMethods.TestResults.cs @@ -39,7 +39,9 @@ private static JToken GetTestResults(JToken p) continue; string message = testCase.Element("failure")?.Element("message")?.Value - ?? testCase.Descendants("message").FirstOrDefault()?.Value; + ?? testCase.Element("reason")?.Element("message")?.Value + ?? testCase.Element("output")?.Value + ?? result; failedTests.Add(new JObject { ["name"] = Attr(testCase, "name"), diff --git a/Editor/MCPServerMethods.Tools.cs b/Editor/MCPServerMethods.Tools.cs index a4a52b3..6d84cf3 100644 --- a/Editor/MCPServerMethods.Tools.cs +++ b/Editor/MCPServerMethods.Tools.cs @@ -94,6 +94,7 @@ private static void AddServerHealthTools(JArray tools) ["requests"] = new JObject { ["type"] = "array", + ["maxItems"] = MaxBatchExecuteRequests, ["items"] = new JObject { ["type"] = "object", @@ -165,7 +166,7 @@ private static void AddPlayerPrefsTools(JArray tools) { tools.Add(CreateTool("get_player_pref", "Get PlayerPref value", new JObject { ["key"] = new JObject { ["type"] = "string" }, ["type"] = new JObject { ["type"] = "string", ["description"] = "int, float, string" }, ["default"] = new JObject { ["type"] = "any" } }, "key")); tools.Add(CreateTool("set_player_pref", "Set PlayerPref value", new JObject { ["key"] = new JObject { ["type"] = "string" }, ["value"] = new JObject { ["type"] = "any" }, ["type"] = new JObject { ["type"] = "string", ["description"] = "int, float, string" } }, "key", "value")); - tools.Add(CreateTool("delete_player_pref", "Delete PlayerPref key or 'all'", new JObject { ["key"] = new JObject { ["type"] = "string", ["description"] = "Specific key or 'all' to clear everything" } })); + tools.Add(CreateTool("delete_player_pref", "Delete PlayerPref key, or delete all with explicit confirmation", new JObject { ["key"] = new JObject { ["type"] = "string", ["description"] = "Specific key, or 'all' only with confirm: true" }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required when key is 'all'; PlayerPrefs deletion is not undoable" } }, "key")); tools.Add(CreateTool("list_player_prefs", "List all PlayerPref keys and values", new JObject { })); } @@ -268,8 +269,8 @@ private static void AddAssetTools(JArray tools) tools.Add(CreateTool("get_dependencies", "Get file deps", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); tools.Add(CreateTool("create_folder", "Create directory", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); tools.Add(CreateTool("read_file", "Read text content", new JObject { ["path"] = new JObject { ["type"] = "string" } }, "path")); - tools.Add(CreateTool("write_file", "Write text content", new JObject { ["path"] = new JObject { ["type"] = "string" }, ["content"] = new JObject { ["type"] = "string" } }, "path", "content")); - tools.Add(CreateTool("write_files_batch", "Write multiple files in a single pass", new JObject { ["files"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "object", ["properties"] = new JObject { ["path"] = new JObject { ["type"] = "string" }, ["content"] = new JObject { ["type"] = "string" } }, ["required"] = new JArray { "path", "content" } } } }, "files")); + tools.Add(CreateTool("write_file", "Write text content", new JObject { ["path"] = new JObject { ["type"] = "string" }, ["content"] = new JObject { ["type"] = "string" }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required when writing .cs files because Unity compilation is triggered" } }, "path", "content")); + tools.Add(CreateTool("write_files_batch", "Write multiple files in a single pass", new JObject { ["files"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "object", ["properties"] = new JObject { ["path"] = new JObject { ["type"] = "string" }, ["content"] = new JObject { ["type"] = "string" } }, ["required"] = new JArray { "path", "content" } } }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required when any file path ends in .cs because Unity compilation is triggered" } }, "files")); } private static void AddEditorControlTools(JArray tools) @@ -298,7 +299,7 @@ private static void AddEditorControlTools(JArray tools) ["search_text"] = new JObject { ["type"] = "string", ["description"] = "Filter by content" } })); tools.Add(CreateTool("clear_logs", "Clear Console", new JObject { })); - tools.Add(CreateTool("attach_script", "Create & Link C#", new JObject { ["script_name"] = new JObject { ["type"] = "string" }, ["script_content"] = new JObject { ["type"] = "string" } }, "script_name")); + tools.Add(CreateTool("attach_script", "Create & Link C#", new JObject { ["script_name"] = new JObject { ["type"] = "string" }, ["script_content"] = new JObject { ["type"] = "string" }, ["confirm"] = new JObject { ["type"] = "boolean", ["description"] = "Required because this writes a .cs file and triggers Unity compilation" } }, "script_name", "confirm")); tools.Add(CreateTool("wait_for_ready", "Wait until server is responsive", new JObject { })); tools.Add(CreateTool("run_tests", "Run NUnit tests in the editor", new JObject { @@ -397,13 +398,24 @@ private static JObject GetPrimitiveSchema() private static JObject GetVector3Schema() => new JObject { - ["type"] = "object", - ["properties"] = new JObject - { - ["x"] = new JObject { ["type"] = "number" }, - ["y"] = new JObject { ["type"] = "number" }, - ["z"] = new JObject { ["type"] = "number" } - } + ["oneOf"] = new JArray( + new JObject + { + ["type"] = "object", + ["properties"] = new JObject + { + ["x"] = new JObject { ["type"] = "number" }, + ["y"] = new JObject { ["type"] = "number" }, + ["z"] = new JObject { ["type"] = "number" } + } + }, + new JObject + { + ["type"] = "array", + ["items"] = new JObject { ["type"] = "number" }, + ["minItems"] = 3, + ["maxItems"] = 3 + }) }; private static string SanitizeScriptName(string n) => System.Text.RegularExpressions.Regex.Replace(n, @"[^a-zA-Z0-9_]", "_"); diff --git a/Editor/MCPServerMethods.Utils.cs b/Editor/MCPServerMethods.Utils.cs index 3eb41a1..41b0010 100644 --- a/Editor/MCPServerMethods.Utils.cs +++ b/Editor/MCPServerMethods.Utils.cs @@ -88,6 +88,19 @@ internal static string ValidateAssetPath(string path) return relativePath; } + private static bool IsCSharpScriptPath(string path) + { + return path.EndsWith(".cs", System.StringComparison.OrdinalIgnoreCase); + } + + private static void RequireScriptWriteConfirmation(JToken p) + { + if (p?["confirm"]?.Value() != true) + { + throw new System.ArgumentException("Writing C# scripts requires confirm: true because it triggers Unity compilation."); + } + } + internal static JObject SerializeVector3(Vector3 v) { return new JObject { ["x"] = v.x, ["y"] = v.y, ["z"] = v.z }; diff --git a/Editor/MCPServerMethods.cs b/Editor/MCPServerMethods.cs index aca83fb..8c1fb50 100644 --- a/Editor/MCPServerMethods.cs +++ b/Editor/MCPServerMethods.cs @@ -40,6 +40,7 @@ internal static void Init() return; } + MCPServer.RefreshMainThreadCachedState(); NexusEditorLog.Log(NexusLogCategory.Api, "[MCP] MCPServerMethods.Init starting..."); _methods.Clear(); ClearCache(); @@ -113,10 +114,11 @@ private static string ProcessJsonRequest(JObject request) string method = request["method"]?.ToString(); if (method == null) return CreateErrorResponse(id, -32600, "Method missing"); - // Fast-path for health checks (execute on current thread, usually listener thread) + // Fast-path health checks run on the listener/current thread. + // Handlers here must only read cached or thread-safe process state. if (method == "get_server_status" || method == "attach_existing_session" || method == "wait_for_asset_import_idle" || method == "wait_for_editor_idle") { try { - JToken result = ExecuteMethod(method, request["params"]); + JToken result = ExecuteMethod(method, request["params"], false); return CreateJsonResponse(id, result); } catch(Exception e) { return CreateExceptionResponse(id, e); } } @@ -215,9 +217,9 @@ private static string CreateExceptionResponse(JToken id, Exception e) return CreateErrorResponse(id, -32000, actualException.Message, stackTrace); } - private static JToken ExecuteMethod(string method, JToken p) + private static JToken ExecuteMethod(string method, JToken p, bool logExecution = true) { - NexusEditorLog.Log(NexusLogCategory.Api, $"[MCP_EXECUTE] {method}"); + if (logExecution) NexusEditorLog.Log(NexusLogCategory.Api, $"[MCP_EXECUTE] {method}"); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); Exception failure = null; try diff --git a/Editor/MCPServerWindow.Integrations.cs b/Editor/MCPServerWindow.Integrations.cs index cff6c7b..4838141 100644 --- a/Editor/MCPServerWindow.Integrations.cs +++ b/Editor/MCPServerWindow.Integrations.cs @@ -110,27 +110,11 @@ private void AutoSetupIntegration(NexusMcpClientInfo client) var refreshed = NexusMcpConfigGenerator.BuildAllForCurrentProject().First(item => item.Kind == client.Kind); refreshed.BridgePath = bridgePath.Replace("\\", "/"); - if (refreshed.Kind == NexusMcpClientKind.Gemini) + if (refreshed.CustomAutoSetup != null) { - MCPCliInstaller.LinkToGemini(); + refreshed.CustomAutoSetup(); DrawIntegrationsTabRefresh(); - SetIntegrationMessage("Gemini setup command finished. Restart Gemini sessions."); - return; - } - - if (refreshed.Kind == NexusMcpClientKind.Antigravity) - { - MCPCliInstaller.LinkToAntigravity(); - DrawIntegrationsTabRefresh(); - SetIntegrationMessage("Antigravity setup command finished. Restart Antigravity sessions."); - return; - } - - if (refreshed.Kind == NexusMcpClientKind.ClaudeCode) - { - MCPCliInstaller.LinkToClaudeCode(); - DrawIntegrationsTabRefresh(); - SetIntegrationMessage("Claude Code setup finished. Run /mcp or restart Claude Code."); + SetIntegrationMessage(refreshed.DisplayName + " setup finished. Restart " + refreshed.DisplayName + " sessions to use the redeployed bridge."); return; } diff --git a/Editor/NexusMcpConfigGenerator.Models.cs b/Editor/NexusMcpConfigGenerator.Models.cs index f9b7d9e..c85bc2c 100644 --- a/Editor/NexusMcpConfigGenerator.Models.cs +++ b/Editor/NexusMcpConfigGenerator.Models.cs @@ -1,3 +1,5 @@ +using System; + namespace UnityMCP.Editor { internal enum NexusMcpClientKind @@ -8,7 +10,9 @@ internal enum NexusMcpClientKind Gemini, Antigravity, Cursor, - VsCodeClineRoo, + VsCode, + Cline, + RooCode, Windsurf, GenericJson } @@ -52,6 +56,11 @@ internal sealed class NexusMcpClientInfo internal bool SupportsAutoSetup { get; set; } internal string AutoSetupDisabledReason { get; set; } internal string RootKey { get; set; } + + // Set for clients whose Auto Setup must shell out to a CLI (e.g. Gemini, Claude Code) instead of + // writing ConfigPath directly. AutoSetupIntegration calls this instead of NexusMcpConfigGenerator.WriteConfig + // when present, keeping CLI-specific handling out of the UI layer's kind-based branching. + internal Action CustomAutoSetup { get; set; } } internal sealed class NexusMcpSetupResult diff --git a/Editor/NexusMcpConfigGenerator.cs b/Editor/NexusMcpConfigGenerator.cs index a38dcb9..349c39a 100644 --- a/Editor/NexusMcpConfigGenerator.cs +++ b/Editor/NexusMcpConfigGenerator.cs @@ -26,24 +26,44 @@ internal static List BuildAll(string bridgePath, string pyth string sourceBridgeVersion = ReadBridgeVersion(normalizedSourceBridgePath); string deployedBridgeVersion = ReadBridgeVersion(normalizedBridgePath); + var geminiClient = CreateCliClient(NexusMcpClientKind.Gemini, "Gemini", "gemini", + "Run Auto Setup, then restart or reopen Gemini CLI sessions.", normalizedBridgePath, pythonPath); + geminiClient.CustomAutoSetup = MCPCliInstaller.LinkToGemini; + + var claudeCodeClient = CreateClaudeCode(projectRoot, normalizedBridgePath, pythonPath); + claudeCodeClient.CustomAutoSetup = MCPCliInstaller.LinkToClaudeCode; + var clients = new List { CreateCodex(normalizedBridgePath, pythonPath, homeDir), CreateClaude(normalizedBridgePath, pythonPath), - CreateClaudeCode(projectRoot, normalizedBridgePath, pythonPath), - CreateCliClient(NexusMcpClientKind.Gemini, "Gemini", "gemini", - "Run Auto Setup, then restart or reopen Gemini CLI sessions.", normalizedBridgePath, pythonPath), - CreateCliClient(NexusMcpClientKind.Antigravity, "Antigravity", "ag", - "Run Auto Setup, then restart Antigravity sessions that use MCP.", normalizedBridgePath, pythonPath), - CreateJsonClient(NexusMcpClientKind.Cursor, "Cursor", Path.Combine(projectRoot, ".cursor", "mcp.json"), - "Paste into .cursor/mcp.json or use Auto Setup for this Unity project.", normalizedBridgePath, pythonPath, "mcpServers"), - CreateJsonClient(NexusMcpClientKind.VsCodeClineRoo, "VS Code / Cline / Roo", Path.Combine(projectRoot, ".vscode", "mcp.json"), - "Paste into .vscode/mcp.json, then restart the MCP client extension.", normalizedBridgePath, pythonPath, "servers"), - CreateJsonClient(NexusMcpClientKind.Windsurf, "Windsurf", Path.Combine(homeDir, ".codeium", "windsurf", "mcp_config.json"), - "Paste into the Windsurf MCP config, then restart Windsurf.", normalizedBridgePath, pythonPath, "mcpServers"), - CreateManual(normalizedBridgePath, pythonPath) + claudeCodeClient, + geminiClient }; + // Plain JSON-config clients - one row per tool, no client-specific behavior beyond path/format. + var jsonClients = new[] + { + (NexusMcpClientKind.Antigravity, "Antigravity", Path.Combine(projectRoot, ".agents", "mcp_config.json"), + "Paste into .agents/mcp_config.json, then restart or reload Antigravity sessions.", "mcpServers"), + (NexusMcpClientKind.Cursor, "Cursor", Path.Combine(projectRoot, ".cursor", "mcp.json"), + "Paste into .cursor/mcp.json or use Auto Setup for this Unity project.", "mcpServers"), + (NexusMcpClientKind.VsCode, "VS Code", Path.Combine(projectRoot, ".vscode", "mcp.json"), + "Paste into .vscode/mcp.json, then restart VS Code's built-in MCP support.", "servers"), + (NexusMcpClientKind.RooCode, "Roo Code", Path.Combine(projectRoot, ".roo", "mcp.json"), + "Paste into .roo/mcp.json, then restart Roo Code.", "mcpServers"), + (NexusMcpClientKind.Cline, "Cline", GetClineConfigPath(homeDir), + "Paste into Cline's global MCP settings, then restart Cline.", "mcpServers"), + (NexusMcpClientKind.Windsurf, "Windsurf", Path.Combine(homeDir, ".codeium", "windsurf", "mcp_config.json"), + "Paste into the Windsurf MCP config, then restart Windsurf.", "mcpServers"), + }; + foreach (var (kind, name, path, instruction, rootKey) in jsonClients) + { + clients.Add(CreateJsonClient(kind, name, path, instruction, normalizedBridgePath, pythonPath, rootKey)); + } + + clients.Add(CreateManual(normalizedBridgePath, pythonPath)); + foreach (var client in clients) { ApplyBridgeVersions(client, normalizedSourceBridgePath, sourceBridgeVersion, deployedBridgeVersion); @@ -100,7 +120,8 @@ internal static string BuildCodexToml(string bridgePath, string pythonPath) { return "[mcp_servers.nexus-unity]\n" + "command = \"" + EscapeToml(NormalizePath(pythonPath)) + "\"\n" + - "args = [ \"" + EscapeToml(NormalizePath(bridgePath)) + "\" ]\n"; + "args = [ \"" + EscapeToml(NormalizePath(bridgePath)) + "\" ]\n" + + "env = { " + MCPServer.AuthTokenEnvironmentVariable + " = \"" + EscapeToml(MCPServer.AuthToken) + "\" }\n"; } internal static string BackupFileIfExists(string path) @@ -130,7 +151,16 @@ private static NexusMcpClientInfo CreateClaude(string bridgePath, string pythonP string path = GetClaudeDesktopConfigPath(); var info = CreateJsonClient(NexusMcpClientKind.ClaudeDesktop, "Claude Desktop", path, "Paste into claude_desktop_config.json, then restart Claude Desktop.", bridgePath, pythonPath, "mcpServers"); - info.SupportsAutoSetup = true; + bool supportedPlatform = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (supportedPlatform) + { + info.SupportsAutoSetup = true; + } + else + { + info.SupportsAutoSetup = false; + info.AutoSetupDisabledReason = "Claude Desktop Auto Setup is only supported on macOS and Windows. Use Copy Config and paste it into Claude Desktop's settings manually."; + } return info; } @@ -165,7 +195,7 @@ private static NexusMcpClientInfo CreateCliClient(NexusMcpClientKind kind, strin info.Instruction = instruction; info.ConfigPath = "Managed by " + command + " CLI"; info.ConfigText = BuildJsonConfig("mcpServers", bridgePath, pythonPath); - bool found = IsExecutableResolved(command) || (kind == NexusMcpClientKind.Antigravity && IsExecutableResolved("antigravity")); + bool found = IsExecutableResolved(command); info.SupportsAutoSetup = found; info.Status = found ? NexusMcpClientStatus.Detected : NexusMcpClientStatus.NotFound; info.StatusDetail = found ? command + " CLI detected." : command + " CLI was not found on PATH."; @@ -216,6 +246,11 @@ private static void ApplyTomlStatus(NexusMcpClientInfo info, string executable) info.Status = NexusMcpClientStatus.Configured; info.StatusDetail = "nexus-unity is present in the config."; + if (!TomlHasCurrentAuthToken(lines)) + { + info.Status = NexusMcpClientStatus.Outdated; + info.StatusDetail = "nexus-unity is missing the current auth token. Run Auto Setup, then restart this client."; + } return; } } @@ -273,6 +308,11 @@ private static void ApplyJsonStatus(NexusMcpClientInfo info) info.Status = NexusMcpClientStatus.Configured; info.StatusDetail = "nexus-unity is present in the config."; + if (!JsonHasCurrentAuthToken(server)) + { + info.Status = NexusMcpClientStatus.Outdated; + info.StatusDetail = "nexus-unity is missing the current auth token. Run Auto Setup, then restart this client."; + } } else { @@ -339,11 +379,21 @@ private static void RemoveTomlSection(List lines) private static JObject BuildServerEntry(string bridgePath, string pythonPath) { - return new JObject + return new JObject { ["command"] = NormalizePath(pythonPath), ["args"] = new JArray { NormalizePath(bridgePath) }, ["env"] = new JObject { [MCPServer.AuthTokenEnvironmentVariable] = MCPServer.AuthToken } }; + } + + private static bool JsonHasCurrentAuthToken(JObject server) => string.Equals((string)server["env"]?[MCPServer.AuthTokenEnvironmentVariable], MCPServer.AuthToken, StringComparison.Ordinal); + + private static bool TomlHasCurrentAuthToken(List lines) + { + string needle = MCPServer.AuthTokenEnvironmentVariable + " = \"" + MCPServer.AuthToken + "\""; + int start = lines.FindIndex(line => line.Trim() == "[mcp_servers.nexus-unity]"); + if (start < 0) return false; + for (int i = start + 1; i < lines.Count && !lines[i].TrimStart().StartsWith("["); i++) { - ["command"] = NormalizePath(pythonPath), - ["args"] = new JArray { NormalizePath(bridgePath) } - }; + if (lines[i].Contains(needle)) return true; + } + return false; } private static bool IsExecutableResolved(string name) @@ -378,5 +428,12 @@ private static string GetClaudeDesktopConfigPath() return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"); } + + // Cline (VS Code extension saoudrizwan.claude-dev) stores MCP settings in VS Code's global storage, + // not in the workspace - same layout on macOS/Windows/Linux, rooted at the OS config dir for VS Code. + private static string GetClineConfigPath(string homeDir) + { + return Path.Combine(homeDir, ".cline", "data", "settings", "cline_mcp_settings.json"); + } } } diff --git a/Editor/nexus_bridge/__init__.py b/Editor/nexus_bridge/__init__.py index e4f8024..8c0d216 100644 --- a/Editor/nexus_bridge/__init__.py +++ b/Editor/nexus_bridge/__init__.py @@ -6,5 +6,4 @@ - :data:`.schemas.STATIC_RESOURCES` — static resources advertised via ``resources/list``. - :func:`.routing.route_tool` — dispatch a tool call by name and arguments. - :func:`.client.call_unity` — low-level JSON-RPC call to the Unity server. -- :data:`.client.logger` — ``logging.Logger`` instance for the package. """ diff --git a/Editor/nexus_bridge/_logging.py b/Editor/nexus_bridge/_logging.py deleted file mode 100644 index 9afd0c5..0000000 --- a/Editor/nexus_bridge/_logging.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Logging utilities for the NexusUnity Python bridge.""" -from __future__ import annotations - -import logging - -logger: logging.Logger = logging.getLogger("nexus_bridge") diff --git a/Editor/nexus_bridge/_transport.py b/Editor/nexus_bridge/_transport.py index efc7cb2..95fb841 100644 --- a/Editor/nexus_bridge/_transport.py +++ b/Editor/nexus_bridge/_transport.py @@ -2,13 +2,15 @@ from __future__ import annotations import json +import math import os import sys +from typing import Any import urllib.request -from ._types import JsonObject, JsonRpcError, JsonRpcRequest, JsonRpcResponse - DEFAULT_PORT: int = 8081 +JsonObject = dict[str, Any] +JsonRpcResponse = dict[str, Any] def _normalize_url(url: str) -> str: @@ -34,7 +36,11 @@ def _read_timeout() -> float: raw_timeout: str | None = os.environ.get("NEXUS_UNITY_TIMEOUT_SECONDS") if not raw_timeout: return 120 - return max(1.0, float(raw_timeout)) + try: + timeout = float(raw_timeout) + except ValueError: + return 120 + return max(1.0, timeout) if math.isfinite(timeout) else 120 UNITY_URL: str = ( @@ -43,21 +49,25 @@ def _read_timeout() -> float: else f"http://127.0.0.1:{_read_port()}/" ) UNITY_TIMEOUT_SECONDS: float = _read_timeout() +AUTH_TOKEN: str | None = os.environ.get("NEXUS_UNITY_AUTH_TOKEN") def call_unity(method: str, params: JsonObject | None = None) -> JsonRpcResponse: - payload: JsonRpcRequest = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": 1} + payload = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": 1} data: bytes = json.dumps(payload).encode("utf-8") + headers: dict[str, str] = {"Content-Type": "application/json"} + if AUTH_TOKEN: + headers["X-Nexus-Unity-Token"] = AUTH_TOKEN req: urllib.request.Request = urllib.request.Request( UNITY_URL, data=data, - headers={"Content-Type": "application/json"}, + headers=headers, ) try: with urllib.request.urlopen(req, timeout=UNITY_TIMEOUT_SECONDS) as response: return json.loads(response.read().decode("utf-8")) except Exception as error: - error_payload: JsonRpcError = { + error_payload = { "code": -32000, "message": f"Unity Server unreachable. Error: {error}", } diff --git a/Editor/nexus_bridge/_types.py b/Editor/nexus_bridge/_types.py deleted file mode 100644 index 6f4532d..0000000 --- a/Editor/nexus_bridge/_types.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Private type definitions for the NexusUnity Python bridge.""" -from __future__ import annotations - -from typing import Any, NotRequired, TypeAlias, TypedDict - -JsonObject: TypeAlias = dict[str, Any] - - -class JsonRpcError(TypedDict): - code: int - message: str - - -class JsonRpcRequest(TypedDict): - jsonrpc: str - method: str - params: JsonObject - id: int - - -class ToolDefinition(TypedDict): - name: str - description: str - inputSchema: JsonObject - - -class ResourceDefinition(TypedDict): - uri: str - name: str - mimeType: NotRequired[str] - - -class JsonRpcResponse(TypedDict, total=False): - result: JsonObject - error: JsonRpcError - - -class TransformArguments(TypedDict, total=False): - instance_id: int - position: JsonObject - rotation: JsonObject - scale: JsonObject - eulerAngles: JsonObject - localScale: JsonObject - - -class WriteFileSpec(TypedDict): - path: str - content: str - - -class WriteError(TypedDict): - path: str - error: JsonRpcError - - -class WaitResultPayload(TypedDict): - status: str - time_waited_seconds: float - - -class TestResultsPayload(WaitResultPayload, total=False): - timestamp_utc: str - message: str - result_path: str - trigger: JsonObject - - -class WriteAndCompileSuccessPayload(WaitResultPayload): - compiler_errors: list[JsonObject] - - -class WriteAndCompileFailurePayload(TypedDict): - status: str - message: str - errors: list[WriteError] diff --git a/Editor/nexus_bridge/_types.py.meta b/Editor/nexus_bridge/_types.py.meta deleted file mode 100644 index 63f4e9b..0000000 --- a/Editor/nexus_bridge/_types.py.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: f0bead8511344b4f920937b1c98230ac -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/nexus_bridge/client.py b/Editor/nexus_bridge/client.py index 123e7d3..6720c8a 100644 --- a/Editor/nexus_bridge/client.py +++ b/Editor/nexus_bridge/client.py @@ -1,13 +1,4 @@ -"""Public import surface for the NexusUnity Python bridge.""" +"""Low-level Unity JSON-RPC client compatibility import.""" from __future__ import annotations -from ._logging import logger -from ._transport import ( - DEFAULT_PORT, - UNITY_TIMEOUT_SECONDS, - UNITY_URL, - _normalize_url, - _read_port, - _read_timeout, - call_unity, -) +from ._transport import call_unity diff --git a/Editor/nexus_bridge/routing.py b/Editor/nexus_bridge/routing.py index b418b14..d920e0a 100644 --- a/Editor/nexus_bridge/routing.py +++ b/Editor/nexus_bridge/routing.py @@ -7,25 +7,13 @@ from __future__ import annotations import time -from typing import Any, Callable, Mapping, Sequence, cast +from typing import Any, Mapping, Sequence -from ._logging import logger from ._transport import call_unity from .schemas import STATIC_TOOLS -from ._types import ( - JsonObject, - JsonRpcError, - JsonRpcResponse, - TestResultsPayload, - TransformArguments, - WaitResultPayload, - WriteAndCompileFailurePayload, - WriteAndCompileSuccessPayload, - WriteError, - WriteFileSpec, -) - -RouteHandler = Callable[[JsonObject], JsonRpcResponse] + +JsonObject = dict[str, Any] +JsonRpcResponse = dict[str, Any] def _compact(params: JsonObject) -> JsonObject: @@ -40,7 +28,7 @@ def _alias(action_name: str | None, aliases: Mapping[str, str]) -> str | None: def _invalid_action(action_name: str | None, valid_actions: Sequence[str]) -> JsonRpcResponse: valid = ", ".join(valid_actions) - error_payload: JsonRpcError = { + error_payload = { "code": -32602, "message": f"Invalid action: {action_name}. Valid actions: {valid}", } @@ -54,14 +42,14 @@ def _result_object(response: JsonRpcResponse | None) -> JsonObject: return result_payload if isinstance(result_payload, dict) else {} -def _error_object(response: JsonRpcResponse | None) -> JsonRpcError | None: +def _error_object(response: JsonRpcResponse | None) -> JsonObject | None: if not response: return None return response.get("error") def _transform_params(args: JsonObject, instance_id: int | None = None) -> JsonObject: - params: TransformArguments = { + params: JsonObject = { "instance_id": instance_id if instance_id is not None else args.get("instance_id") } for key in ["position", "rotation", "scale", "eulerAngles", "localScale"]: @@ -125,13 +113,13 @@ def _run_tests_wait(args: JsonObject) -> JsonRpcResponse: current_results_payload.get("status") == "Success" and current_results_payload.get("timestamp_utc") != previous_timestamp ): - test_results = cast(TestResultsPayload, dict(current_results_payload)) + test_results = dict(current_results_payload) test_results["time_waited_seconds"] = round(time.time() - start_time, 2) return {"result": test_results} time.sleep(poll_interval) - timeout_result: TestResultsPayload = { + timeout_result = { "status": "Timeout", "message": "Timed out waiting for a new Unity TestResults XML file.", "time_waited_seconds": round(time.time() - start_time, 2), @@ -173,7 +161,7 @@ def _wait_for_compilation(timeout: float, start_time: float | None = None) -> Js else: status = "Timeout" - wait_result: WaitResultPayload = { + wait_result = { "status": status, "time_waited_seconds": round(time.time() - start_time, 2), } @@ -187,22 +175,23 @@ def _route_list_tools(_: JsonObject) -> JsonRpcResponse: def _route_write_and_compile(args: JsonObject) -> JsonRpcResponse: - files: list[WriteFileSpec] = args.get("files", []) + files = args.get("files", []) + confirm = args.get("confirm") start_time: float = time.time() call_unity("clear_logs") - write_errors: list[WriteError] = [] + write_errors: list[JsonObject] = [] for file_info in files: write_file_response = call_unity( "write_file", - {"path": file_info["path"], "content": file_info["content"]}, + _compact({"path": file_info["path"], "content": file_info["content"], "confirm": confirm}), ) write_error = _error_object(write_file_response) if write_error is not None: write_errors.append({"path": file_info["path"], "error": write_error}) if write_errors: - failure_result: WriteAndCompileFailurePayload = { + failure_result = { "status": "Failed", "message": "Failed to write some files", "errors": write_errors, @@ -223,7 +212,7 @@ def _route_write_and_compile(args: JsonObject) -> JsonRpcResponse: if log_entry.get("Type") in ["Error", "Exception", "Assert"]: compiler_errors.append(log_entry) - success_result: WriteAndCompileSuccessPayload = { + success_result = { "status": "Failed" if compiler_errors else wait_status, "time_waited_seconds": time_waited_seconds, "compiler_errors": compiler_errors, @@ -414,7 +403,7 @@ def _route_playerprefs_manager(args: JsonObject) -> JsonRpcResponse: if action == "set": return call_unity("set_player_pref", {"key": args.get("key"), "value": args.get("value"), "type": args.get("type", "string")}) if action == "delete": - return call_unity("delete_player_pref", {"key": args.get("key")}) + return call_unity("delete_player_pref", _compact({"key": args.get("key"), "confirm": args.get("confirm")})) if action == "list": return call_unity("list_player_prefs") return _invalid_action(action, ["get", "set", "delete", "list"]) @@ -460,14 +449,14 @@ def _route_wait(args: JsonObject) -> JsonRpcResponse: else: status = "Timeout" - wait_result: WaitResultPayload = { + wait_result = { "status": status, "time_waited_seconds": round(time.time() - start_time, 2), } return {"result": wait_result} -_HANDLERS: dict[str, RouteHandler] = { +_HANDLERS = { "tools/list": _route_list_tools, "list_tools": _route_list_tools, "listTools": _route_list_tools, diff --git a/Editor/nexus_bridge/schemas.py b/Editor/nexus_bridge/schemas.py index a58f22f..3f2ac48 100644 --- a/Editor/nexus_bridge/schemas.py +++ b/Editor/nexus_bridge/schemas.py @@ -8,32 +8,43 @@ """ from __future__ import annotations -from ._types import JsonObject, ResourceDefinition, ToolDefinition +from typing import Any + +JsonObject = dict[str, Any] # --- Shared sub-schemas --- VECTOR3_SCHEMA: JsonObject = { - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"}, - "z": {"type": "number"}, - }, + "oneOf": [ + { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"}, + }, + }, + { + "type": "array", + "items": {"type": "number"}, + "minItems": 3, + "maxItems": 3, + }, + ], } -STATIC_TOOLS: list[ToolDefinition] = [ +STATIC_TOOLS: list[JsonObject] = [ # --- Consolidated Core Managers --- { "name": "unity_scene_manager", "description": "Unified scene management (create, open, save, list)", "inputSchema": { "type": "object", - "properties": { - "action": {"type": "string", "enum": ["create", "create_scene", "open", "open_scene", "save", "save_scene", "list", "list_scenes"]}, - "name": {"type": "string"}, - "path": {"type": "string"}, - "open_if_exists": {"type": "boolean"} - }, - "required": ["action"] + "oneOf": [ + {"properties": {"action": {"type": "string", "enum": ["create", "create_scene"]}, "name": {"type": "string"}, "path": {"type": "string"}, "open_if_exists": {"type": "boolean"}}, "required": ["action"]}, + {"properties": {"action": {"type": "string", "enum": ["open", "open_scene"]}, "path": {"type": "string"}}, "required": ["action", "path"]}, + {"properties": {"action": {"type": "string", "enum": ["save", "save_scene"]}, "path": {"type": "string"}}, "required": ["action"]}, + {"properties": {"action": {"type": "string", "enum": ["list", "list_scenes"]}}, "required": ["action"]} + ] } }, { @@ -267,21 +278,39 @@ "oneOf": [ {"description": "Read a PlayerPref value by key", "properties": {"action": {"const": "get"}, "key": {"type": "string"}, "type": {"type": "string", "enum": ["string", "int", "float"]}}, "required": ["action", "key"]}, {"description": "Write a PlayerPref value", "properties": {"action": {"const": "set"}, "key": {"type": "string"}, "value": {"type": "string"}, "type": {"type": "string", "enum": ["string", "int", "float"]}}, "required": ["action", "key", "value"]}, - {"description": "Delete a PlayerPref entry by key", "properties": {"action": {"const": "delete"}, "key": {"type": "string"}}, "required": ["action", "key"]}, + {"description": "Delete a PlayerPref entry by key; use key 'all' with confirm true to clear all PlayerPrefs", "properties": {"action": {"const": "delete"}, "key": {"type": "string"}, "confirm": {"type": "boolean"}}, "required": ["action", "key"]}, {"description": "List all stored PlayerPref keys and their values", "properties": {"action": {"const": "list"}}, "required": ["action"]} ] } }, # --- Specialized Diagnostics --- - {"name": "unity_write_and_compile", "description": "High-level macro: Writes multiple files, waits for domain reload, and returns compiler errors. Use for ALL code changes.", "inputSchema": {"type": "object", "properties": {"files": {"type": "array", "items": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, "required": ["files"]}}, + { + "name": "unity_write_and_compile", + "description": "High-level macro: Writes multiple files, waits for domain reload, and returns compiler errors. Use for ALL code changes.", + "inputSchema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"], + }, + }, + "confirm": {"type": "boolean", "description": "Required when writing .cs files because Unity compilation is triggered"}, + }, + "required": ["files"], + }, + }, {"name": "unity_invoke_method", "description": "Invoke a C# method on a component via reflection", "inputSchema": {"type": "object", "properties": {"instance_id": {"type": "integer"}, "component_name": {"type": "string"}, "method_name": {"type": "string"}, "arguments": {"type": "array"}}, "required": ["instance_id", "method_name"]}}, {"name": "unity_dump_scene_graph", "description": "Dump recursive tree of active scene with components and key fields", "inputSchema": {"type": "object", "properties": {"root_id": {"type": "integer"}, "max_depth": {"type": "integer"}, "include_all_properties": {"type": "boolean"}}}}, {"name": "unity_get_scene_dependencies", "description": "Return a scene-wide dependency map of cross-object references", "inputSchema": {"type": "object", "properties": {}}}, {"name": "unity_lint_project", "description": "Run Roslyn-based C# audit of the entire project", "inputSchema": {"type": "object", "properties": {}}} ] -STATIC_RESOURCES: list[ResourceDefinition] = [ +STATIC_RESOURCES: list[JsonObject] = [ { "uri": "unity://docs/api-reference", "name": "API Reference", diff --git a/Editor/nexus_unity_bridge.py b/Editor/nexus_unity_bridge.py index 8cdc6dd..8e03add 100644 --- a/Editor/nexus_unity_bridge.py +++ b/Editor/nexus_unity_bridge.py @@ -47,9 +47,28 @@ def consume_positional_port_arg() -> None: from nexus_bridge.schemas import STATIC_TOOLS, STATIC_RESOURCES from nexus_bridge.routing import route_tool -from nexus_bridge._logging import logger + +logger = logging.getLogger("nexus_bridge") PARENT_PID = os.getppid() +RESOURCE_FILES = { + "unity://docs/api-reference": "API_REFERENCE.MD", + "unity://docs/setup": "README.md", +} + + +def read_resource(uri: str) -> dict[str, Any]: + filename = RESOURCE_FILES.get(uri) + if filename is None: + return {"error": {"code": -32602, "message": f"Unknown resource: {uri}"}} + + for root in [CURRENT_DIR, os.path.dirname(CURRENT_DIR)]: + path = os.path.join(root, filename) + if os.path.isfile(path): + with open(path, encoding="utf-8") as handle: + return {"result": {"contents": [{"uri": uri, "mimeType": "text/markdown", "text": handle.read()}]}} + + return {"error": {"code": -32000, "message": f"Resource file not found: {filename}"}} def orphan_monitor() -> None: """Monitor if the parent process (AI CLI) is still alive.""" @@ -122,6 +141,9 @@ def main() -> None: response = {"jsonrpc": "2.0", "id": req_id, "result": {"tools": STATIC_TOOLS}} elif method in ["resources/list", "listResources"]: response = {"jsonrpc": "2.0", "id": req_id, "result": {"resources": STATIC_RESOURCES}} + elif method in ["resources/read", "readResource"]: + resource_response = read_resource(request.get("params", {}).get("uri", "")) + response = {"jsonrpc": "2.0", "id": req_id, **resource_response} elif method in ["tools/call", "callTool"]: params = request.get("params", {}) name = params.get("name", "").replace("unity_", "") diff --git a/Editor/tests/test_routing.py b/Editor/tests/test_routing.py index cb17ebe..bd3a50d 100644 --- a/Editor/tests/test_routing.py +++ b/Editor/tests/test_routing.py @@ -95,6 +95,15 @@ def test_editor_controller_run_tests_wait_delegates_to_helper(self) -> None: self.assertEqual(expected_response, response) mock_run_tests_wait.assert_called_once_with({"action": "run_tests_wait", "timeout_seconds": 9}) + def test_playerprefs_delete_forwards_confirm(self) -> None: + with patch("nexus_bridge.routing.call_unity", return_value={"result": {"ok": True}}) as mock_call_unity: + response: dict[str, Any] = routing._route_playerprefs_manager( + {"action": "delete", "key": "all", "confirm": True} + ) + + self.assertEqual({"result": {"ok": True}}, response) + mock_call_unity.assert_called_once_with("delete_player_pref", {"key": "all", "confirm": True}) + def test_hierarchy_manager_create_applies_transform_after_create(self) -> None: create_response: dict[str, Any] = {"result": {"data": {"instance_id": 12}}} transform_response: dict[str, Any] = {"result": {"ok": True}} @@ -128,7 +137,7 @@ def test_write_and_compile_returns_write_errors_without_waiting(self) -> None: with patch("nexus_bridge.routing.call_unity", side_effect=call_results) as mock_call_unity: with patch("nexus_bridge.routing._wait_for_compilation") as mock_wait_for_compilation: - response: dict[str, Any] = routing._route_write_and_compile({"files": files}) + response: dict[str, Any] = routing._route_write_and_compile({"files": files, "confirm": True}) self.assertEqual("Failed", response["result"]["status"]) self.assertEqual("Failed to write some files", response["result"]["message"]) @@ -140,8 +149,8 @@ def test_write_and_compile_returns_write_errors_without_waiting(self) -> None: self.assertEqual( [ call("clear_logs"), - call("write_file", {"path": "Assets/One.cs", "content": "one"}), - call("write_file", {"path": "Assets/Two.cs", "content": "two"}), + call("write_file", {"path": "Assets/One.cs", "content": "one", "confirm": True}), + call("write_file", {"path": "Assets/Two.cs", "content": "two", "confirm": True}), ], mock_call_unity.call_args_list, ) @@ -162,7 +171,7 @@ def test_write_and_compile_waits_and_filters_compiler_errors(self) -> None: with patch("nexus_bridge.routing.time.time", return_value=10.0): with patch("nexus_bridge.routing.call_unity", side_effect=[None, {"result": {"ok": True}}, log_response]) as mock_call_unity: with patch("nexus_bridge.routing._wait_for_compilation", return_value=wait_response) as mock_wait_for_compilation: - response: dict[str, Any] = routing._route_write_and_compile({"files": files}) + response: dict[str, Any] = routing._route_write_and_compile({"files": files, "confirm": True}) self.assertEqual("Failed", response["result"]["status"]) self.assertEqual(1.5, response["result"]["time_waited_seconds"]) @@ -177,7 +186,7 @@ def test_write_and_compile_waits_and_filters_compiler_errors(self) -> None: self.assertEqual( [ call("clear_logs"), - call("write_file", {"path": "Assets/Test.cs", "content": "class Test {}"}), + call("write_file", {"path": "Assets/Test.cs", "content": "class Test {}", "confirm": True}), call("read_logs", {"count": 200}), ], mock_call_unity.call_args_list, diff --git a/Editor/tests/test_schemas.py b/Editor/tests/test_schemas.py index 9962919..2e6b328 100644 --- a/Editor/tests/test_schemas.py +++ b/Editor/tests/test_schemas.py @@ -26,6 +26,17 @@ def _action_values(variant: dict[str, Any]) -> set[str]: return set(action_schema["enum"]) +class SceneManagerSchemaTests(unittest.TestCase): + def test_scene_manager_keeps_action_specific_requirements(self) -> None: + scene_manager = _get_tool("unity_scene_manager") + variants = scene_manager["inputSchema"]["oneOf"] + open_variant = next(variant for variant in variants if "open_scene" in _action_values(variant)) + list_variant = next(variant for variant in variants if "list_scenes" in _action_values(variant)) + + self.assertCountEqual(["action", "path"], open_variant["required"]) + self.assertCountEqual(["action"], list_variant["required"]) + + class HierarchyManagerSchemaTests(unittest.TestCase): def test_hierarchy_manager_uses_action_specific_variants(self) -> None: hierarchy_manager = _get_tool("unity_hierarchy_manager") @@ -78,5 +89,26 @@ def test_set_sibling_index_accepts_integer_or_edge_keywords(self) -> None: ) +class PlayerPrefsManagerSchemaTests(unittest.TestCase): + def test_delete_variant_advertises_optional_confirm(self) -> None: + playerprefs_manager = _get_tool("unity_playerprefs_manager") + delete_variant = next( + variant + for variant in playerprefs_manager["inputSchema"]["oneOf"] + if "delete" in _action_values(variant) + ) + + self.assertCountEqual(["action", "key"], delete_variant["required"]) + self.assertEqual({"type": "boolean"}, delete_variant["properties"]["confirm"]) + + +class WriteAndCompileSchemaTests(unittest.TestCase): + def test_write_and_compile_advertises_confirm(self) -> None: + tool = _get_tool("unity_write_and_compile") + properties = tool["inputSchema"]["properties"] + + self.assertEqual("boolean", properties["confirm"]["type"]) + + if __name__ == "__main__": unittest.main() diff --git a/Editor/tests/test_transport.py b/Editor/tests/test_transport.py index 2f6823b..eb38d4f 100644 --- a/Editor/tests/test_transport.py +++ b/Editor/tests/test_transport.py @@ -83,6 +83,17 @@ def test_call_unity_sends_expected_json_rpc_payload(self) -> None: payload, ) + def test_call_unity_sends_auth_token_header_when_configured(self) -> None: + transport: Any = _reload_transport_module({"NEXUS_UNITY_AUTH_TOKEN": "secret-token"}, ["nexus_unity_bridge.py"]) + fake_response: _FakeHttpResponse = _FakeHttpResponse(b'{"result": {"ok": true}}') + + with patch("nexus_bridge._transport.urllib.request.urlopen", return_value=fake_response) as mock_urlopen: + transport.call_unity("get_state") + + request: Any = mock_urlopen.call_args.args[0] + headers: dict[str, str] = {key.lower(): value for key, value in request.header_items()} + self.assertEqual("secret-token", headers["x-nexus-unity-token"]) + class ReadPortTests(unittest.TestCase): def test_read_port_prefers_environment_variable(self) -> None: @@ -122,6 +133,10 @@ def test_timeout_is_clamped_to_minimum_of_one_second(self) -> None: transport: Any = _reload_transport_module({"NEXUS_UNITY_TIMEOUT_SECONDS": "0.2"}, ["nexus_unity_bridge.py"]) self.assertEqual(1.0, transport.UNITY_TIMEOUT_SECONDS) + def test_timeout_ignores_invalid_environment_variable(self) -> None: + transport: Any = _reload_transport_module({"NEXUS_UNITY_TIMEOUT_SECONDS": "oops"}, ["nexus_unity_bridge.py"]) + self.assertEqual(120, transport.UNITY_TIMEOUT_SECONDS) + if __name__ == "__main__": unittest.main() diff --git a/LICENSE.md b/LICENSE.md index f288702..43e1b8e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,674 +1,21 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +MIT License + +Copyright (c) 2026 Fork Horizon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 09a190d..97704a2 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Nexus Unity -[![Tag](https://img.shields.io/github/v/tag/ForkHorizon/NexusUnity?sort=semver&label=release)](https://github.com/ForkHorizon/NexusUnity/releases/tag/v1.4.2) -[![License: GPL-3.0-only](https://img.shields.io/badge/license-GPL--3.0--only-blue.svg)](LICENSE.md) +[![Tag](https://img.shields.io/github/v/tag/ForkHorizon/NexusUnity?sort=semver&label=release)](https://github.com/ForkHorizon/NexusUnity/releases/tag/v1.5.0) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md) [![Unity](https://img.shields.io/badge/Unity-6000.0%2B-black?logo=unity)](package.json) [![Validate package](https://github.com/ForkHorizon/NexusUnity/actions/workflows/validate.yml/badge.svg)](https://github.com/ForkHorizon/NexusUnity/actions/workflows/validate.yml) Nexus Unity is an open source Unity Editor automation package. It runs a local JSON-RPC server inside the Unity Editor and exposes scene, asset, code, log, test, inspection, and UI automation tools to trusted local developer workflows. - Package id: `com.forkhorizon.nexus.unity` -- Version: `1.4.2` -- License: `GPL-3.0-only` +- Version: `1.5.0` +- License: `MIT` - Public repository: `https://github.com/ForkHorizon/NexusUnity.git` ## Status -Active public release. Current version: `1.4.2`. +Active public release. Current version: `1.5.0`. The public API is maintained for local Unity Editor automation workflows, while new tools and bridge improvements are tracked under `[Unreleased]` in `CHANGELOG.md` until the next tagged release. @@ -48,7 +48,7 @@ https://github.com/ForkHorizon/NexusUnity.git For reproducible installs, pin the public release tag: ```text -https://github.com/ForkHorizon/NexusUnity.git#v1.4.2 +https://github.com/ForkHorizon/NexusUnity.git#v1.5.0 ``` Nexus Unity does not declare Unity Project Auditor packages. Its lint tool always runs Nexus style and scene checks, and only includes Unity Project Auditor findings when the host project explicitly has compatible Project Auditor rules installed. @@ -63,7 +63,7 @@ Nexus Unity does not declare Unity Project Auditor packages. Its lint tool alway http://127.0.0.1:8081/ ``` -The server is intended for trusted local automation only. It validates loopback hosts and browser origins, constrains file operations to the Unity project root, and limits request payload size. +The server is intended for trusted local automation only. It validates loopback hosts and browser origins, requires the per-session `X-Nexus-Unity-Token` header generated by the Integrations setup flow, constrains file operations to the Unity project root, and limits request payload size. ## Editor Menu @@ -83,13 +83,22 @@ Console logging defaults to `Important`, which shows key lifecycle/setup message Nexus Unity supports two public surfaces: - Raw HTTP JSON-RPC tools: unprefixed Unity method names returned by `list_tools`. -- MCP bridge tools: consolidated `unity_` manager tools optimized for AI clients. +- MCP bridge tools: consolidated `unity_` manager tools optimized for AI clients, plus static docs through `resources/list` and `resources/read`. + +PlayerPrefs deletion is guarded: deleting all entries requires `key: "all"` and `confirm: true`, and cannot be undone through Unity Undo. + +C# script writes are guarded: `attach_script`, `write_file`, `write_files_batch`, and `unity_write_and_compile` require `confirm: true` when they write `.cs` files and trigger Unity compilation. + +Raw `batch_execute` is capped at 50 requests and rejects nested `batch_execute` calls. + +Fast-path health calls (`get_server_status`, `attach_existing_session`, `wait_for_asset_import_idle`, and `wait_for_editor_idle`) return cached editor state so they can run safely from the listener thread. Direct JSON-RPC example: ```bash curl -s http://127.0.0.1:8081/ \ -H 'Content-Type: application/json' \ + -H "X-Nexus-Unity-Token: $NEXUS_UNITY_AUTH_TOKEN" \ -d '{"jsonrpc":"2.0","method":"get_server_status","params":{},"id":1}' ``` @@ -105,7 +114,7 @@ The Unity window can also deploy the bridge to the project root for CLIs that pr For Codex, Claude Desktop, Claude Code, Gemini, Antigravity, Cursor, VS Code/Cline/Roo, Windsurf, or compatible MCP clients, open `Window > Nexus Unity` and use the `Integrations` tab. -> **Claude Code vs. Claude Desktop:** these are different products with different config locations. The `Claude Code` card registers the `nexus-unity` server in a project-scoped `.mcp.json` at the Unity project root (using the `claude` CLI when it is on `PATH`, otherwise writing the file directly). The `Claude Desktop` card writes the desktop app's `claude_desktop_config.json`. After running `Auto Setup` for Claude Code, run `/mcp` inside Claude Code (or restart it) to load the server. +> **Claude Code vs. Claude Desktop:** these are different products with different config locations. The `Claude Code` card registers the `nexus-unity` server in a project-scoped `.mcp.json` at the Unity project root (using the `claude` CLI when it is on `PATH`, otherwise writing the file directly). If the CLI cannot remove an existing registration, setup skips its add command and writes `.mcp.json` directly instead. The `Claude Desktop` card writes the desktop app's `claude_desktop_config.json`. After running `Auto Setup` for Claude Code, run `/mcp` inside Claude Code (or restart it) to load the server. Each integration card shows `Detected`, `Not found`, `Configured`, `Outdated`, or `Error` status and provides: @@ -113,11 +122,11 @@ Each integration card shows `Detected`, `Not found`, `Configured`, `Outdated`, o - `Copy Config` for manual setup. - `Open Config` when the client uses a known config file path. -Nexus Unity generates configs from the resolved Python interpreter and the deployed `nexus_unity_bridge.py` path. User/global config writes create a timestamped backup before modifying an existing file. +Nexus Unity generates configs from the resolved Python interpreter and the deployed `nexus_unity_bridge.py` path. Antigravity uses the workspace-local `.agents/mcp_config.json`; its older global Gemini config location is not used. User/global config writes create a timestamped backup before modifying an existing file. -> **Windows / Linux:** The Python interpreter is resolved as `python3`, then `python`, then the Windows `py` launcher — make sure a Python 3 install is on `PATH`. CLI-based cards (Codex, Gemini, Antigravity, and the Claude Code CLI path) are detected with `where` on Windows and `which` on macOS/Linux, so the CLI must be on the `PATH` of the shell that launched Unity for `Auto Setup` to find it; otherwise use `Copy Config`. +> **Windows / Linux:** The Python interpreter is resolved as `python3`, then `python`, then the Windows `py` launcher — make sure a Python 3 install is on `PATH`. CLI-based cards (Codex, Gemini, and the Claude Code CLI path) are detected with `where` on Windows and `which` on macOS/Linux, so the CLI must be on the `PATH` of the shell that launched Unity for `Auto Setup` to find it; otherwise use `Copy Config`. -`Outdated` means the client config points at a different Unity project, the project-root bridge has not been deployed, or the deployed bridge version differs from the package bridge version. Run `Auto Setup`, then restart the affected MCP client session so it loads the current bridge. +`Outdated` means the client config points at a different Unity project, the project-root bridge has not been deployed, the auth token is missing or stale, or the deployed bridge version differs from the package bridge version. Run `Auto Setup`, then restart the affected MCP client session so it loads the current bridge. Package path: @@ -126,7 +135,10 @@ Package path: "mcpServers": { "nexus-unity": { "command": "python3", - "args": ["Packages/com.forkhorizon.nexus.unity/Editor/nexus_unity_bridge.py"] + "args": ["Packages/com.forkhorizon.nexus.unity/Editor/nexus_unity_bridge.py"], + "env": { + "NEXUS_UNITY_AUTH_TOKEN": "" + } } } } @@ -139,7 +151,10 @@ Root-deployed path: "mcpServers": { "nexus-unity": { "command": "python3", - "args": ["nexus_unity_bridge.py"] + "args": ["nexus_unity_bridge.py"], + "env": { + "NEXUS_UNITY_AUTH_TOKEN": "" + } } } } @@ -163,16 +178,20 @@ Current development keeps the public API backward-compatible while tightening sc - Use `unity_write_and_compile` for code-edit workflows. Older references to `unity_apply_code_change` are outdated and should not be used for the public MCP bridge. - `update_component` accepts the preferred `properties` object and the legacy `json_data` string form. -- `unity_scene_manager` accepts obvious aliases such as `list_scenes`, `create_scene`, `open_scene`, and `save_scene`; invalid manager actions now report the valid action names. +- `unity_scene_manager` accepts obvious aliases such as `list_scenes`, `create_scene`, `open_scene`, and `save_scene` while preserving action-specific required parameters; invalid manager actions now report the valid action names. - `create_scene` accepts optional `path` and `open_if_exists` so agents can create or reopen a deterministic scene asset in one call. - `create_primitive` accepts optional `name`, `parent_id`, `position`, `rotation`, `scale`, and `material_path`, which lets agents build visible non-origin objects without fragile follow-up calls. +- Vector3 fields accept either `{ "x": 0, "y": 1, "z": 0 }` or `[0, 1, 0]`. - `set_transform` updates position, rotation, and scale. +- `get_game_object` returns `transform` and compact `components` data for cheap verify-after-write reads. +- Script writes require `confirm: true` before Unity compilation is triggered, then keep readiness probes busy while the scheduled refresh is pending. +- Play Mode transitions keep readiness probes busy until Unity finishes entering or exiting Play Mode. - `create_material` accepts optional `path`, `base_color` / `color`, and `emission_color` so generated materials can be created inside a chosen project folder and made visibly distinct. -- `write_file` and `write_files_batch` create missing parent directories after path validation. +- `write_file` and `write_files_batch` create missing parent directories after path validation; `.cs` writes require `confirm: true`. - `invoke_method.arguments` is an optional positional JSON array. - `click_object_in_game` accepts a documented `instance_id` target and still supports hierarchy `path` lookup. - Unity 6.x editor identity differences are handled internally: Nexus keeps JSON `instance_id` values stable while using Unity 6.4+ `EntityId` APIs when available and Unity 6.3-compatible instance ID APIs otherwise. -- `get_test_results` reads Unity `TestResults*.xml` summaries from the project root or Unity persistent data path; `unity_editor_controller` exposes `run_tests_wait` as a bridge-side polling workflow. +- `get_test_results` reads Unity `TestResults*.xml` summaries from the project root or Unity persistent data path, using scoped failure/reason/output messages for non-passing cases; `unity_editor_controller` exposes `run_tests_wait` as a bridge-side polling workflow. - `get_tool_usage_stats` reports in-memory raw tool counts, durations, and errors since Unity domain load without storing request payloads; `reset_tool_usage_stats` clears that state for scoped diagnostics. Both are also available through `unity_editor_controller`. - `ui_get_window_rect`, `ui_set_window_rect`, and `ui_capture_window_snapshot` support automated layout checks for editor windows. @@ -187,7 +206,7 @@ Current development keeps the public API backward-compatible while tightening sc | `CODE_OF_CONDUCT.md` | Community behavior expectations | | `RELEASE.md` | Public release checklist and smoke test | | `CHANGELOG.md` | Public release history | -| `LICENSE.md` | GPL-3.0-only license text | +| `LICENSE.md` | MIT license text | ## Contributor Validation @@ -195,6 +214,8 @@ GitHub Actions is the required validation gate for public contributions. The `Va Static validation includes `NexusQualityGate`, a Roslyn-based checker for production C# source under `Editor/` and `Runtime/`. It blocks missing XML documentation on public/protected types and methods, generic filler summaries, files over 450 lines, and methods over 150 lines. Files over 300 lines and methods over 50 lines are reported as warnings so contributors can split code before it becomes hard to review. +Editor security tests include JSON-RPC traversal checks for representative file and asset methods. + The runner must have the labels `self-hosted`, `macOS`, `ARM64`, and `nexus-unity-ci`; the AI review job also requires `nexus-doc-ai`. The local toolchain must provide `python3`, `dotnet`, Unity `6000.4.3f1`, and Ollama on `http://127.0.0.1:11434`. The workflow uses GitHub Actions `concurrency` group `nexus-unity-ci` with `queue: max`, so multiple trusted runs wait in order instead of competing for the MacBook. The required `Documentation quality AI` job also runs checklist quality review. It sends documentation/code excerpts to local Ollama and verifies that XML comments match the implementation intent, major caller-visible side effects, and Unity Editor behavior. It reviews each pull request checklist item individually against repository evidence, CI wiring, safety policies, and tracked files, then emits a specific next action when a checklist item is not satisfied. The AI rubric is intentionally pragmatic: it blocks misleading or filler docs and clear checklist violations, not comments that omit every private implementation detail or runtime evidence that is supplied by another CI job. The job sets a short Ollama `keep_alive` and unloads the model after every run so large local models do not remain in memory between checks. @@ -244,9 +265,9 @@ For integration tests, open the Unity project, start the Nexus Unity server from ## Development Versioning -Do not bump `package.json` for every change while development is unreleased. Keep the package at the latest public release version, currently `1.4.2`, and record user-visible work under `[Unreleased]` in `CHANGELOG.md`. +Do not bump `package.json` for every change while development is unreleased. Keep the package at the latest public release version, currently `1.5.0`, and record user-visible work under `[Unreleased]` in `CHANGELOG.md`. -When maintainers prepare a release, move the accumulated `[Unreleased]` entries to the new version section, update `package.json` and the visible version strings in `README.md`, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`, then tag the release. Unity Package Manager and GitHub releases both use semantic `MAJOR.MINOR.PATCH` versions such as `1.4.2` and `v1.4.2`. Reserve patch bumps for urgent compatible hotfixes. +When maintainers prepare a release, move the accumulated `[Unreleased]` entries to the new version section, update `package.json` and the visible version strings in `README.md`, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`, then tag the release. Unity Package Manager and GitHub releases both use semantic `MAJOR.MINOR.PATCH` versions such as `1.5.0` and `v1.5.0`. Reserve patch bumps for urgent compatible hotfixes. ## Community @@ -256,6 +277,8 @@ To support ongoing development, use the repository Sponsor button configured thr ## Release Notes +The `1.5.0` release relicenses Nexus Unity under MIT and hardens the public local-automation surface: every HTTP/WebSocket request now requires the session token; script writes and bulk PlayerPrefs deletion have explicit confirmation gates; raw batches are bounded; and readiness reports are safe during import and Play Mode transitions. It also improves cheap GameObject read-back, primitive validation, bridge resources, current MCP client setup, path-security coverage, test-result reporting, and cross-platform CLI launch reliability. See the complete, migration-relevant detail in the `1.5.0` changelog entry. + The `1.4.2` patch removes direct Unity Project Auditor dependencies to avoid clean-install Console spam, keeps Nexus lint checks available without Project Auditor rules, and tightens the Python MCP bridge schemas, routing types, and compile/test polling behavior. The `1.4.1` patch adds the missing package folder `.meta` files (`docs/`, `docs/assets/`, `Editor/tests/`) so fresh installs no longer log "no meta file" warnings, and hardens the pre-push `.meta` validation to check the git-tracked tree and every folder. diff --git a/RELEASE.md b/RELEASE.md index 6c3d9a7..d7a8fff 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,8 +6,8 @@ This checklist is for publishing `com.forkhorizon.nexus.unity` as an open source - Package id: `com.forkhorizon.nexus.unity` - Public repository: `https://github.com/ForkHorizon/NexusUnity.git` -- License: `GPL-3.0-only` -- Current public version: `1.4.2` +- License: `MIT` +- Current public version: `1.5.0` - Minimum Unity version: `6000.0` ## Development Versioning @@ -27,7 +27,7 @@ Use `CHANGELOG.md` as the source of truth during development: - Keep compatibility notes and migration guidance in the docs while the work is unreleased. - Prepare the next semantic version only when cutting a release branch or release commit. -Unity Package Manager requires `MAJOR.MINOR.PATCH` in `package.json`, for example `1.4.2`. GitHub release tags, titles, and announcements use the same semantic version: `v1.4.2` for tags and `1.4.2` for release titles. +Unity Package Manager requires `MAJOR.MINOR.PATCH` in `package.json`, for example `1.5.0`. GitHub release tags, titles, and announcements use the same semantic version: `v1.5.0` for tags and `1.5.0` for release titles. When preparing the release, choose the version by semantic versioning: @@ -39,8 +39,8 @@ When preparing the release, choose the version by semantic versioning: 1. Verify `Assets/NexusUnity/package.json`: - `name` is `com.forkhorizon.nexus.unity`. - - `version` matches the Unity package version, such as `1.4.2`. - - `license` is `GPL-3.0-only`. + - `version` matches the Unity package version, such as `1.5.0`. + - `license` is `MIT`. - Repository, documentation, changelog, and license URLs point to the public repository. 2. Verify docs: - `README.md` install command uses the public Git URL. diff --git a/Tests~/Editor/AgentToolingTests.cs b/Tests~/Editor/AgentToolingTests.cs index 05b0320..2c94f27 100644 --- a/Tests~/Editor/AgentToolingTests.cs +++ b/Tests~/Editor/AgentToolingTests.cs @@ -1,5 +1,6 @@ using System.IO; using System.Linq; +using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -68,6 +69,18 @@ public void GetTestResultsParsesFailingXml() Assert.AreEqual("Expected true", failure?["message"]?.ToString()); } + [Test] + public void GetTestResultsUsesOnlyScopedMessagesForNonPassingXml() + { + WriteTestResults("No assertionsWrong nested messageWrong nested message"); + + JObject result = RpcResult("get_test_results", new JObject { ["result_path"] = _resultPath }); + JArray failures = (JArray)result["failed_tests"]; + + Assert.AreEqual("No assertions", failures[0]?["message"]?.ToString()); + Assert.AreEqual("Error", failures[1]?["message"]?.ToString()); + } + [Test] public void ToolUsageStatsTrackCountsAndErrorsWithoutPayloads() { @@ -100,6 +113,68 @@ public void ResetToolUsageStatsClearsPreviousCalls() Assert.IsFalse(tools.Children().Any(t => t["method"]?.ToString() == "get_editor_state")); } + [Test] + public void FastPathMethodsCanRunOffMainThread() + { + NexusConsoleLogMode originalLogMode = MCPSettings.ConsoleLogMode; + string[] methods = + { + "get_server_status", + "attach_existing_session", + "wait_for_asset_import_idle", + "wait_for_editor_idle" + }; + + try + { + MCPSettings.ConsoleLogMode = NexusConsoleLogMode.All; + + foreach (string method in methods) + { + JObject response = Task.Run(() => Rpc(method, new JObject { ["timeout_seconds"] = 0 })).GetAwaiter().GetResult(); + + Assert.IsNull(response["error"], $"{method}: {response.ToString(Formatting.None)}"); + } + } + finally + { + MCPSettings.ConsoleLogMode = originalLogMode; + } + } + + [Test] + public void BatchExecuteRejectsOversizedBatches() + { + var requests = new JArray(); + for (int i = 0; i < 51; i++) + { + requests.Add(new JObject { ["method"] = "get_server_status", ["params"] = new JObject() }); + } + + JObject response = Rpc("batch_execute", new JObject { ["requests"] = requests }); + + StringAssert.Contains("at most 50 requests", response["error"]?["message"]?.ToString()); + } + + [Test] + public void BatchExecuteRejectsRecursiveRequests() + { + JObject result = RpcResult("batch_execute", new JObject + { + ["requests"] = new JArray + { + new JObject + { + ["method"] = "batch_execute", + ["params"] = new JObject { ["requests"] = new JArray() } + } + } + }); + + Assert.AreEqual("Error", result["results"]?[0]?["status"]?.ToString()); + StringAssert.Contains("Recursive batch_execute", result["results"]?[0]?["message"]?.ToString()); + } + [Test] public void UiWindowRectMethodsRoundTrip() { diff --git a/Tests~/Editor/ConsolidatedManagersTests.cs b/Tests~/Editor/ConsolidatedManagersTests.cs index 3db5af4..9bc7b4a 100644 --- a/Tests~/Editor/ConsolidatedManagersTests.cs +++ b/Tests~/Editor/ConsolidatedManagersTests.cs @@ -7,6 +7,7 @@ using System; using System.Reflection; using System.Collections.Generic; +using System.Linq; namespace UnityMCP.Editor.Tests { public class ConsolidatedManagersTests { @@ -139,7 +140,11 @@ private JToken SimulateBridgeRouting(string managerName, JObject args) { string action = args["action"]?.ToString(); if (action == "get") { mappedMethod = "get_player_pref"; mappedParams = new JObject { ["key"] = args["key"], ["type"] = args["type"] ?? "string" }; } else if (action == "set") { mappedMethod = "set_player_pref"; mappedParams = new JObject { ["key"] = args["key"], ["value"] = args["value"], ["type"] = args["type"] ?? "string" }; } - else if (action == "delete") { mappedMethod = "delete_player_pref"; mappedParams = new JObject { ["key"] = args["key"] }; } + else if (action == "delete") { + mappedMethod = "delete_player_pref"; + mappedParams = new JObject { ["key"] = args["key"] }; + CopyIfPresent(args, mappedParams, "confirm"); + } else if (action == "list") { mappedMethod = "list_player_prefs"; mappedParams = new JObject(); } } else if (managerName == "unity_wait") { @@ -288,6 +293,44 @@ public void UnityHierarchyManager_CreatePrimitiveWithNameAndTransform_ReturnsPla Assert.AreEqual(2f, go.transform.localScale.z, 0.001f); } + [Test] + public void UnityHierarchyManager_CreatePrimitiveWithArrayPosition_ReturnsPlacedObject() { + var res = SimulateBridgeRouting("unity_hierarchy_manager", new JObject { + ["action"] = "create_primitive", + ["primitive_type"] = "Cube", + ["name"] = "ArrayPlacedManagerCube", + ["position"] = new JArray(9, 9, 9) + }); + + Assert.IsNotNull(res["result"], $"Expected result, got error: {res["error"]}"); + var id = res["result"]["data"]?["instance_id"]?.Value(); + Assert.IsTrue(id.HasValue && id.Value != 0); + + var go = EditorUtility.InstanceIDToObject(id.Value) as GameObject; + Assert.IsNotNull(go); + _createdObjects.Add(go); + Assert.AreEqual(9f, go.transform.position.x, 0.001f); + Assert.AreEqual(9f, go.transform.position.y, 0.001f); + Assert.AreEqual(9f, go.transform.position.z, 0.001f); + } + + [Test] + public void UnityHierarchyManager_CreatePrimitiveWithInvalidArrayPosition_DoesNotCreateObject() { + string name = "InvalidArrayCube_" + Guid.NewGuid().ToString("N"); + int before = GameObject.FindObjectsOfType().Count(go => go.name == name); + + var res = SimulateBridgeRouting("unity_hierarchy_manager", new JObject { + ["action"] = "create_primitive", + ["primitive_type"] = "Cube", + ["name"] = name, + ["position"] = new JArray(9, 9) + }); + + Assert.IsNotNull(res["error"], "Expected invalid Vector3 array to fail."); + int after = GameObject.FindObjectsOfType().Count(go => go.name == name); + Assert.AreEqual(before, after); + } + [Test] public void UnityHierarchyManager_RenameAlias_UpdatesGameObjectName() { var go = CreateTestGameObject(); @@ -324,6 +367,54 @@ public void UnityHierarchyManager_SetTransform_UpdatesPositionRotationAndScale() Assert.AreEqual(4f, go.transform.localScale.z, 0.001f); } + [Test] + public void GetGameObject_ReturnsTransformAndComponentsForReadBackVerification() { + var go = CreateTestGameObject(); + + var transformRes = SimulateBridgeRouting("unity_hierarchy_manager", new JObject { + ["action"] = "set_transform", + ["instance_id"] = go.GetInstanceID(), + ["position"] = new JObject { ["x"] = -1, ["y"] = 2, ["z"] = 3 }, + ["rotation"] = new JObject { ["x"] = 10, ["y"] = 20, ["z"] = 30 }, + ["scale"] = new JObject { ["x"] = 2, ["y"] = 3, ["z"] = 4 } + }); + Assert.IsNotNull(transformRes["result"], $"Expected result, got error: {transformRes["error"]}"); + + var addComponentRes = CallRaw("add_component", new JObject { ["instance_id"] = go.GetInstanceID(), ["component_name"] = "BoxCollider" }); + Assert.IsNotNull(addComponentRes["result"], $"Expected result, got error: {addComponentRes["error"]}"); + + var readRes = CallRaw("get_game_object", new JObject { ["instance_id"] = go.GetInstanceID() }); + Assert.IsNotNull(readRes["result"], $"Expected result, got error: {readRes["error"]}"); + var data = readRes["result"]["data"]; + Assert.IsNotNull(data); + Assert.AreEqual(-1f, data["transform"]["position"]["x"].Value(), 0.001f); + Assert.AreEqual(2f, data["transform"]["position"]["y"].Value(), 0.001f); + Assert.AreEqual(3f, data["transform"]["position"]["z"].Value(), 0.001f); + Assert.AreEqual(10f, data["transform"]["rotation"]["x"].Value(), 0.001f); + Assert.AreEqual(20f, data["transform"]["rotation"]["y"].Value(), 0.001f); + Assert.AreEqual(30f, data["transform"]["rotation"]["z"].Value(), 0.001f); + Assert.AreEqual(2f, data["transform"]["scale"]["x"].Value(), 0.001f); + Assert.AreEqual(3f, data["transform"]["scale"]["y"].Value(), 0.001f); + Assert.AreEqual(4f, data["transform"]["scale"]["z"].Value(), 0.001f); + + var components = data["components"] as JArray; + Assert.IsNotNull(components); + Assert.IsTrue(components.Any(c => c["type"]?.ToString() == "Transform")); + Assert.IsTrue(components.Any(c => c["type"]?.ToString() == "BoxCollider")); + } + + [Test] + public void AddComponent_StaleInstanceId_ReturnsGameObjectNotFound() { + var go = CreateTestGameObject(); + int staleId = go.GetInstanceID(); + GameObject.DestroyImmediate(go); + + var res = CallRaw("add_component", new JObject { ["instance_id"] = staleId, ["component_name"] = "BoxCollider" }); + + Assert.IsNotNull(res["error"], res.ToString()); + StringAssert.Contains("GameObject not found", res["error"]["message"].ToString()); + } + [Test] public void UnityComponentManager_Inspect_ReturnsSuccess() { var go = CreateTestGameObject(); @@ -378,6 +469,38 @@ public void UnityPlayerPrefsManager_List_ReturnsSuccess() { Assert.IsNotNull(res["result"]); } + [Test] + public void DeletePlayerPref_EmptyOrMissingKey_ReturnsErrorWithoutDeletingPrefs() { + string key = "NexusUnity_DeleteGuard_" + Guid.NewGuid().ToString("N"); + PlayerPrefs.SetString(key, "keep"); + PlayerPrefs.Save(); + + try { + var missing = CallRaw("delete_player_pref", new JObject()); + Assert.IsNotNull(missing["error"], missing.ToString()); + Assert.IsTrue(PlayerPrefs.HasKey(key)); + + var empty = CallRaw("delete_player_pref", new JObject { ["key"] = "" }); + Assert.IsNotNull(empty["error"], empty.ToString()); + Assert.IsTrue(PlayerPrefs.HasKey(key)); + } + finally { + PlayerPrefs.DeleteKey(key); + PlayerPrefs.Save(); + } + } + + [Test] + public void DeletePlayerPref_ToolSchemaRequiresKeyAndAdvertisesConfirm() { + var tools = (JArray)CallRaw("list_tools", new JObject())["result"]; + var tool = (JObject)tools.First(item => item["name"]?.ToString() == "delete_player_pref"); + var schema = (JObject)tool["inputSchema"]; + var properties = (JObject)schema["properties"]; + + CollectionAssert.Contains(((JArray)schema["required"]).Select(item => item.ToString()).ToArray(), "key"); + Assert.AreEqual("boolean", properties["confirm"]["type"]?.ToString()); + } + [Test] public void UnityEditorController_ReadLogs_ReturnsSuccess() { var res = SimulateBridgeRouting("unity_editor_controller", new JObject { ["action"] = "read_logs", ["count"] = 10 }); diff --git a/Tests~/Editor/MCPCliInstallerTests.cs b/Tests~/Editor/MCPCliInstallerTests.cs new file mode 100644 index 0000000..b145992 --- /dev/null +++ b/Tests~/Editor/MCPCliInstallerTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using NUnit.Framework; + +namespace UnityMCP.Editor.Tests +{ + public class MCPCliInstallerTests + { + [Test] + public void ClaudeCodeMissingRegistrationIsSafeToAdd() + { + Assert.IsTrue(MCPCliInstaller.IsClaudeCodeRegistrationAbsent("MCP server 'nexus-unity' not found.")); + Assert.IsFalse(MCPCliInstaller.IsClaudeCodeRegistrationAbsent("Could not read project configuration.")); + } + + [Test] + public void CreateProcessStartInfoPreservesShellMetacharactersAsArguments() + { + var method = typeof(MCPCliInstaller).GetMethod( + "CreateProcessStartInfo", + BindingFlags.NonPublic | BindingFlags.Static, + null, + new[] { typeof(string), typeof(string[]) }, + null); + + var args = new[] { "mcp", "add", "nexus-unity", "--env", "TOKEN=a;$(touch bad)", "--", "/tmp/py thon", "/tmp/proj\";echo bad/nexus_unity_bridge.py" }; + var psi = (ProcessStartInfo)method.Invoke(null, new object[] { "/tmp/cli;echo bad", args }); + + Assert.AreEqual("/tmp/cli;echo bad", psi.FileName); + Assert.IsTrue(psi.Arguments.Contains("TOKEN=a;$(touch bad)")); + Assert.IsTrue(psi.Arguments.Contains("/tmp/proj\";echo bad/nexus_unity_bridge.py")); + Assert.IsFalse(psi.Arguments.Contains(" -c ")); + Assert.IsFalse(psi.UseShellExecute); + } + + [Test] + public void WindowsBatchArgumentsEscapeCmdMetacharacters() + { + var method = typeof(MCPCliInstaller).GetMethod( + "BuildWindowsBatchArguments", + BindingFlags.NonPublic | BindingFlags.Static, + null, + new[] { typeof(string), typeof(string[]) }, + null); + + var args = new[] { "mcp", "add", "nexus-unity", "a&b|c%PATH%" }; + string command = (string)method.Invoke(null, new object[] { @"C:\Tools\gemini.cmd", args }); + + Assert.IsTrue(command.Contains(@"C:\Tools\gemini.cmd")); + Assert.IsTrue(command.Contains("a^&b^|c^%PATH^%")); + Assert.IsFalse(command.Contains(" a&b|c%PATH%")); + } + + [Test] + public void CreateProcessStartInfoPassesMetacharactersAsLiteralArgv() + { + if (!File.Exists("/bin/sh")) Assert.Ignore("Requires a Unix shell for the argv smoke test."); + + string root = Path.Combine(Path.GetTempPath(), "NexusCliInstallerTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string script = Path.Combine(root, "argv.sh"); + string output = Path.Combine(root, "argv.txt"); + + try + { + File.WriteAllText(script, "#!/bin/sh\nout=\"$1\"\nshift\nprintf '%s\\n' \"$@\" > \"$out\"\n"); + var method = typeof(MCPCliInstaller).GetMethod( + "CreateProcessStartInfo", + BindingFlags.NonPublic | BindingFlags.Static, + null, + new[] { typeof(string), typeof(string[]) }, + null); + string[] args = { script, output, "TOKEN=a;$(touch bad)", "/tmp/py thon", "/tmp/proj\";echo bad" }; + var psi = (ProcessStartInfo)method.Invoke(null, new object[] { "/bin/sh", args }); + + using (Process process = Process.Start(psi)) + { + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, error); + } + + CollectionAssert.AreEqual(new[] { "TOKEN=a;$(touch bad)", "/tmp/py thon", "/tmp/proj\";echo bad" }, File.ReadAllLines(output)); + Assert.IsFalse(File.Exists(Path.Combine(root, "bad"))); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + } +} diff --git a/Tests~/Editor/NexusMcpConfigGeneratorTests.cs b/Tests~/Editor/NexusMcpConfigGeneratorTests.cs index 5396fef..8d0b241 100644 --- a/Tests~/Editor/NexusMcpConfigGeneratorTests.cs +++ b/Tests~/Editor/NexusMcpConfigGeneratorTests.cs @@ -20,6 +20,10 @@ public void BuildsExpectedConfigSnippets() Assert.IsTrue(vsCode.Contains("\"servers\"")); Assert.IsTrue(codex.Contains("[mcp_servers.nexus-unity]")); Assert.IsTrue(codex.Contains("command = \"/usr/bin/python3\"")); + + var jsonRoot = JObject.Parse(json); + Assert.AreEqual(MCPServer.AuthToken, (string)jsonRoot["mcpServers"]["nexus-unity"]["env"][MCPServer.AuthTokenEnvironmentVariable]); + Assert.IsTrue(codex.Contains(MCPServer.AuthTokenEnvironmentVariable + " = \"" + MCPServer.AuthToken + "\"")); } [Test] @@ -57,7 +61,7 @@ public void WriteVsCodeConfigUsesServersRoot() try { var client = NexusMcpConfigGenerator.BuildAll("/bridge.py", "/python3", Path.Combine(root, "Project"), Path.Combine(root, "Home")) - .First(item => item.Kind == NexusMcpClientKind.VsCodeClineRoo); + .First(item => item.Kind == NexusMcpClientKind.VsCode); var result = NexusMcpConfigGenerator.WriteConfig(client); var json = JObject.Parse(File.ReadAllText(client.ConfigPath)); @@ -72,6 +76,43 @@ public void WriteVsCodeConfigUsesServersRoot() } } + [Test] + public void AntigravityUsesWorkspaceMcpConfig() + { + string root = CreateTempRoot(); + try + { + string projectRoot = Path.Combine(root, "Project"); + var antigravity = NexusMcpConfigGenerator.BuildAll("/bridge.py", "/python3", projectRoot, Path.Combine(root, "Home")) + .First(item => item.Kind == NexusMcpClientKind.Antigravity); + + Assert.AreEqual(Path.Combine(projectRoot, ".agents", "mcp_config.json"), antigravity.ConfigPath); + Assert.AreEqual(NexusMcpConfigFormat.JsonMcpServers, antigravity.Format); + } + finally + { + DeleteTempRoot(root); + } + } + + [Test] + public void ClineUsesCurrentSharedSettingsPath() + { + string root = CreateTempRoot(); + try + { + string homeRoot = Path.Combine(root, "Home"); + var cline = NexusMcpConfigGenerator.BuildAll("/bridge.py", "/python3", Path.Combine(root, "Project"), homeRoot) + .First(item => item.Kind == NexusMcpClientKind.Cline); + + Assert.AreEqual(Path.Combine(homeRoot, ".cline", "data", "settings", "cline_mcp_settings.json"), cline.ConfigPath); + } + finally + { + DeleteTempRoot(root); + } + } + [Test] public void DetectsConfiguredJsonServer() { @@ -100,6 +141,34 @@ public void DetectsConfiguredJsonServer() } } + [Test] + public void DetectsJsonServerMissingCurrentAuthToken() + { + string root = CreateTempRoot(); + try + { + string projectRoot = Path.Combine(root, "Project"); + string homeRoot = Path.Combine(root, "Home"); + string sourceBridge = Path.Combine(root, "Package", "nexus_unity_bridge.py"); + string deployedBridge = Path.Combine(projectRoot, "nexus_unity_bridge.py"); + WriteBridge(sourceBridge, "1.2.0"); + WriteBridge(deployedBridge, "1.2.0"); + string configPath = Path.Combine(projectRoot, ".cursor", "mcp.json"); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + File.WriteAllText(configPath, "{ \"mcpServers\": { \"nexus-unity\": { \"command\": \"/python3\", \"args\": [\"" + deployedBridge.Replace("\\", "/") + "\"] } } }"); + + var configured = NexusMcpConfigGenerator.BuildAll(deployedBridge, "/python3", projectRoot, homeRoot, sourceBridge) + .First(item => item.Kind == NexusMcpClientKind.Cursor); + + Assert.AreEqual(NexusMcpClientStatus.Outdated, configured.Status); + StringAssert.Contains("auth token", configured.StatusDetail); + } + finally + { + DeleteTempRoot(root); + } + } + [Test] public void DetectsOutdatedDeployedBridgeVersion() { diff --git a/Tests~/Editor/PathSecurityTests.cs b/Tests~/Editor/PathSecurityTests.cs index 3dd74c3..e06c734 100644 --- a/Tests~/Editor/PathSecurityTests.cs +++ b/Tests~/Editor/PathSecurityTests.cs @@ -3,6 +3,8 @@ using UnityEditor; using System.IO; using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace UnityMCP.Editor.Tests { @@ -11,6 +13,12 @@ namespace UnityMCP.Editor.Tests /// public class PathSecurityTests { + [SetUp] + public void InitRegistry() + { + MCPServerMethods.Init(); + } + /// /// Verifies that ValidatePath prevents access to sibling directories with similar prefixes. /// @@ -64,5 +72,117 @@ public void ValidatePath_PreventsParentDirectoryAccess() var ex = Assert.Throws(() => MCPServerMethods.ValidatePath(parentPath)); Assert.That(ex.Message, Does.Contain("Access denied")); } + + [Test] + public void ReadFile_PreventsTraversalThroughJsonRpc() + { + string projectRoot = Path.GetDirectoryName(Application.dataPath).Replace('\\', '/'); + string outsidePath = Path.Combine(projectRoot, "..", "nexus-traversal.txt").Replace('\\', '/'); + + JObject response = Rpc("read_file", new JObject { ["path"] = outsidePath }); + + AssertRpcErrorContains(response, "Access denied"); + } + + [Test] + public void ExploreAsset_PreventsTraversalThroughJsonRpc() + { + JObject response = Rpc("explore_asset", new JObject { ["path"] = "Assets/../../nexus-traversal.asset" }); + + AssertRpcErrorContains(response, "Access denied"); + } + + [Test] + public void WriteFile_CSharpRequiresConfirm() + { + string path = "Assets/NexusUnityGeneratedTests/BlockedScript.cs"; + DeleteGeneratedRoot(); + + try + { + JObject response = Rpc("write_file", new JObject { ["path"] = path, ["content"] = "class BlockedScript {}" }); + + AssertRpcErrorContains(response, "confirm: true"); + Assert.IsFalse(File.Exists(MCPServerMethods.ValidatePath(path))); + } + finally + { + DeleteGeneratedRoot(); + } + } + + [Test] + public void WriteFilesBatch_CSharpRequiresConfirmBeforeAnyWrite() + { + string textPath = "Assets/NexusUnityGeneratedTests/Allowed.txt"; + string scriptPath = "Assets/NexusUnityGeneratedTests/BlockedBatchScript.cs"; + DeleteGeneratedRoot(); + + try + { + JObject response = Rpc("write_files_batch", new JObject + { + ["files"] = new JArray + { + new JObject { ["path"] = textPath, ["content"] = "should not be written first" }, + new JObject { ["path"] = scriptPath, ["content"] = "class BlockedBatchScript {}" } + } + }); + + AssertRpcErrorContains(response, "confirm: true"); + Assert.IsFalse(File.Exists(MCPServerMethods.ValidatePath(textPath))); + Assert.IsFalse(File.Exists(MCPServerMethods.ValidatePath(scriptPath))); + } + finally + { + DeleteGeneratedRoot(); + } + } + + [Test] + public void AttachScript_RequiresConfirm() + { + string path = "Assets/BlockedAttachScript.cs"; + string fullPath = MCPServerMethods.ValidatePath(path); + if (File.Exists(fullPath)) File.Delete(fullPath); + + try + { + JObject response = Rpc("attach_script", new JObject { ["script_name"] = "BlockedAttachScript" }); + + AssertRpcErrorContains(response, "confirm: true"); + Assert.IsFalse(File.Exists(fullPath)); + } + finally + { + if (File.Exists(fullPath)) File.Delete(fullPath); + } + } + + private static JObject Rpc(string method, JObject parameters) + { + var request = new JObject + { + ["jsonrpc"] = "2.0", + ["method"] = method, + ["params"] = parameters, + ["id"] = 1 + }; + + return JObject.Parse(MCPServerMethods.ProcessJsonRpc(request.ToString(Formatting.None))); + } + + private static void AssertRpcErrorContains(JObject response, string expectedMessage) + { + Assert.IsNull(response["result"], response.ToString(Formatting.None)); + Assert.IsNotNull(response["error"], response.ToString(Formatting.None)); + Assert.That(response["error"]?["message"]?.ToString(), Does.Contain(expectedMessage)); + } + + private static void DeleteGeneratedRoot() + { + string fullPath = MCPServerMethods.ValidatePath("Assets/NexusUnityGeneratedTests"); + if (Directory.Exists(fullPath)) Directory.Delete(fullPath, true); + } } } diff --git a/Tests~/Editor/ServerPortTests.cs b/Tests~/Editor/ServerPortTests.cs index 464e709..3de66a5 100644 --- a/Tests~/Editor/ServerPortTests.cs +++ b/Tests~/Editor/ServerPortTests.cs @@ -47,6 +47,17 @@ public void IsPortBusy_DetectsLocalListener() } } + [Test] + public void AuthToken_IsRequiredForNetworkRequests() + { + string token = MCPServer.AuthToken; + + Assert.IsFalse((bool)InvokePrivateMethod("IsAuthorizedToken", null)); + Assert.IsFalse((bool)InvokePrivateMethod("IsAuthorizedToken", "")); + Assert.IsFalse((bool)InvokePrivateMethod("IsAuthorizedToken", "wrong-token")); + Assert.IsTrue((bool)InvokePrivateMethod("IsAuthorizedToken", token)); + } + [Test] public void GetPortOwner_IdentifiesProcess() { diff --git a/package.json b/package.json index 0a212d5..55c3294 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "com.forkhorizon.nexus.unity", - "version": "1.4.2", + "version": "1.5.0", "displayName": "Nexus Unity", "description": "Open source Unity Editor automation server for local AI tools and developer workflows.", "unity": "6000.0", - "license": "GPL-3.0-only", + "license": "MIT", "documentationUrl": "https://github.com/ForkHorizon/NexusUnity/blob/main/DOCUMENTATION.MD", "changelogUrl": "https://github.com/ForkHorizon/NexusUnity/blob/main/CHANGELOG.md", "licensesUrl": "https://github.com/ForkHorizon/NexusUnity/blob/main/LICENSE.md", diff --git a/scripts/prepush-validate.sh b/scripts/prepush-validate.sh index 57a23c2..abd345e 100755 --- a/scripts/prepush-validate.sh +++ b/scripts/prepush-validate.sh @@ -32,7 +32,7 @@ with open("package.json", encoding="utf-8") as handle: assert package["name"] == "com.forkhorizon.nexus.unity" assert package["version"] -assert package["license"] == "GPL-3.0-only" +assert package["license"] == "MIT" assert package["repository"]["url"] == "https://github.com/ForkHorizon/NexusUnity.git" dependencies = package.get("dependencies", {})