Skip to content

fix(server): stop silent cross-instance routing when multiple editors are connected (#1023)#1236

Open
Scriptwonder wants to merge 3 commits into
CoplayDev:betafrom
Scriptwonder:fix/issue-1023-session-isolation
Open

fix(server): stop silent cross-instance routing when multiple editors are connected (#1023)#1236
Scriptwonder wants to merge 3 commits into
CoplayDev:betafrom
Scriptwonder:fix/issue-1023-session-isolation

Conversation

@Scriptwonder

@Scriptwonder Scriptwonder commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Addresses the dangerous half of #1023 (agents writing into the wrong project when 2+ editors are open).

Problem

Two independent routing hazards on the Python server:

  1. stdio silently guesses the target editor. With multiple editors connected and no instance selected, UnityConnectionPool._resolve_instance_id routes to the "most recently active" instance by heartbeat — nondeterministic per call, so consecutive tool calls can land in different projects. The reporter had editor settings overwritten, submodules force-deleted, and source files overwritten in the wrong project. HTTP was already hardened to raise InstanceSelectionRequiredError on ambiguity; stdio (the default transport) never got the same guard.
  2. Concurrent local clients share one active-instance slot. UnityInstanceMiddleware.get_session_key falls back to a shared "global"/client_id key, so two clients on one server stomp each other's set_active_instance selection.

Fix

  • stdio: when no instance identifier is given, no default is pinned, and more than one instance is connected, raise a structured instance-selection-required error listing the available Name@hash ids (mirroring the HTTP hub's semantics) instead of silently picking by heartbeat. Single-instance auto-routing is unchanged.
  • middleware: active-instance persistence is delegated to FastMCP session state, falling back to client_id | user:{user_id} | "global" only where session identity genuinely doesn't exist, so concurrent clients keep isolated selections.

Verification

cd Server && uv run pytest tests/ -q1305 passed, 3 skipped on this branch rebased onto current beta, including new test_stdio_instance_resolution.py (multi-instance refusal matrix) and reworked instance-routing/characterization tests.

Relationship to other open work

Summary by CodeRabbit

  • Bug Fixes
    • Improved active Unity instance tracking using per-session state, making instance handling consistent across requests.
    • When multiple Unity instances exist and no default is set, the system now requires you to explicitly choose an instance (no more automatic “most recent” guess).
    • Instance-selection failures now return clearer guidance, including the current session identifier and a select_instance hint with available instance IDs.
  • Tests
    • Added/updated integration and routing tests to cover per-session state persistence and the new instance-resolution behavior.

…ion state (CoplayDev#1023)

UnityInstanceMiddleware previously kept its own dict (`_active_by_key`) keyed
by `client_id | user:{user_id} | "global"`. The MCP protocol's identity is
session-based, not client-based:

  - Streamable HTTP uses `Mcp-Session-Id` per the 2025-11-25 spec
  - stdio is intrinsically 1:1 (one subprocess = one session)
  - `client_id` is the peer-declared Implementation name, which is not
    unique — two Cursor instances both report `"cursor"` and collide

The `"global"` fallback compounded this: anonymous clients all collapsed onto
one record, so client A's `set_active_instance` retargeted client B (CoplayDev#1023).

FastMCP already exposes session-isolated state via `ctx.set_state` /
`ctx.get_state`, prefixed by `ctx.session_id` (verified in fastmcp 3.x
`server/context.py:1181-1238` — reads `mcp-session-id` header on HTTP,
generates a per-session UUID on stdio, caches on the session object).
Switching to it removes a whole concurrency surface and closes the bug class
by construction.

Changes:
- Replace `_active_by_key + _lock + get_session_key` with two-liner
  set/get/clear_active_instance backed by `ctx.set_state` under
  `mcpforunity.active_instance`.
- `set_active_instance` tool now returns `session_id` (the real key) instead
  of the bogus `session_key` field.
- `debug_request_context` drops `derived_key` and `all_keys_in_store`
  (no global dict to enumerate; per-session reads still report
  `active_instance` and the real `session_id`).
- Drop characterization/integration tests that pinned the removed
  `client_id → key → "global"` behaviour. Add a regression test that
  proves two ctxs with private state dicts can't read each other's
  active instance.

No protocol or wire-format change. The behavior shift is invisible to
single-client setups and removes the cross-client leak for multi-client
HTTP deployments.
…nnected (CoplayDev#1023)

The stdio connection pool's _resolve_instance_id silently returned the
most-recently-heartbeated editor when no unity_instance and no default
were supplied, letting an unbound session retarget another project's
Unity. Mirror the HTTP "multiple connected, no active set" guard: select
the sole instance when there is exactly one, otherwise raise ConnectionError
listing the available ids and requiring an explicit selection.

Pairs with the cherry-picked middleware fix that keys active-instance
state by ctx.session_id instead of the peer-supplied client_id.
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR moves active Unity instance storage to FastMCP session state, updates tool responses to return session_id and active-instance data, adds structured available-instance details to selection errors, and changes stdio resolution to fail on ambiguous multi-instance cases. Related integration and characterization tests were updated.

Changes

Session-state persistence and selection flow

Layer / File(s) Summary
Middleware session-state rewrite
Server/src/transport/unity_instance_middleware.py
Adds a session-state key constant and rewrites active-instance set/get/clear behavior to use ctx.set_state and ctx.get_state instead of in-memory storage.
Tool response updates to session_id
Server/src/services/tools/debug_request_context.py, Server/src/services/tools/set_active_instance.py
Updates debug and set-active-instance tools to read the active instance from middleware and return session_id in success responses instead of session_key.
Multi-instance ambiguity guard and transport errors
Server/src/transport/legacy/unity_connection.py, Server/src/transport/plugin_hub.py, Server/src/transport/unity_transport.py
Changes unhinted stdio resolution to reject ambiguous multi-instance cases, extends selection-required errors with available instance IDs, and maps that error to a structured select_instance response.
Stdio resolution regression tests
Server/tests/test_stdio_instance_resolution.py
Adds regression coverage for single-instance resolution, ambiguous multi-instance failure, default-instance overrides, explicit identifiers, and the no-instance error case.
Integration tests for session-scoped middleware
Server/tests/integration/test_instance_routing_comprehensive.py, Server/tests/integration/test_middleware_auth_integration.py
Adds stateful context helpers, verifies per-session persistence and isolation, updates per-request injection assertions, and removes session-key fallback coverage.
Characterization updates for session state and selection errors
Server/tests/test_transport_characterization.py
Switches characterization helpers to session-scoped state, replaces older isolation/thread-safety checks, and adds assertions for available instance data plus HTTP selection-error hints.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • CoplayDev/unity-mcp#422: Both PRs modify debug_request_context.py and unity_instance_middleware.py around active-instance/session-key handling.
  • CoplayDev/unity-mcp#644: Both PRs modify unity_instance_middleware.py and routing paths that inject or resolve Unity instance state.
  • CoplayDev/unity-mcp#772: Both PRs change unity_instance_middleware and set_active_instance behavior for resolving and storing unity_instance routing state.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the problem, fix, and verification, but it omits several required template sections and fields. Add the missing template sections and fill in type of change, changes made, compatibility/package source, testing, docs updates, related issues, and additional notes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: preventing silent cross-instance routing when multiple editors are connected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Scriptwonder Scriptwonder added the bug Something isn't working label Jul 4, 2026
@codecov-commenter

codecov-commenter commented Jul 4, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 84.61538% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Server/src/transport/plugin_hub.py 71.42% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Scriptwonder

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Server/tests/test_transport_characterization.py (1)

143-186: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Session isolation and repeated-update coverage matches the new middleware contract.

These tests correctly reflect the FastMCP session-scoped set_state/get_state behavior described in unity_instance_middleware.py. One gap: the removed test_middleware_thread_safe_updates isn't replaced with an equivalent concurrent-write test for the new implementation — test_middleware_repeated_updates_settle_to_latest only checks sequential writes within one session, not concurrent writes racing on the same session's state. Since FastMCP's session state store is now responsible for concurrency safety, this may be an acceptable trade-off, but worth confirming there's no remaining assumption in the middleware layer that requires local synchronization for concurrent writes to the same session.

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

In `@Server/tests/test_transport_characterization.py` around lines 143 - 186, The
middleware tests now cover session isolation and sequential overwrite behavior,
but they no longer verify concurrent writes against the same session. Add a
concurrency-focused test alongside test_middleware_isolates_multiple_sessions
and test_middleware_repeated_updates_settle_to_latest that exercises
UnityInstanceMiddleware.set_active_instance/get_active_instance under racing
updates to one ctx, so the FastMCP session-state contract is covered without
relying on any local synchronization assumptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Server/tests/test_transport_characterization.py`:
- Around line 143-186: The middleware tests now cover session isolation and
sequential overwrite behavior, but they no longer verify concurrent writes
against the same session. Add a concurrency-focused test alongside
test_middleware_isolates_multiple_sessions and
test_middleware_repeated_updates_settle_to_latest that exercises
UnityInstanceMiddleware.set_active_instance/get_active_instance under racing
updates to one ctx, so the FastMCP session-state contract is covered without
relying on any local synchronization assumptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5fee2498-ab7d-49ee-ac44-cf3c89686f67

📥 Commits

Reviewing files that changed from the base of the PR and between d87ca19 and eb67603.

📒 Files selected for processing (8)
  • Server/src/services/tools/debug_request_context.py
  • Server/src/services/tools/set_active_instance.py
  • Server/src/transport/legacy/unity_connection.py
  • Server/src/transport/unity_instance_middleware.py
  • Server/tests/integration/test_instance_routing_comprehensive.py
  • Server/tests/integration/test_middleware_auth_integration.py
  • Server/tests/test_stdio_instance_resolution.py
  • Server/tests/test_transport_characterization.py
💤 Files with no reviewable changes (1)
  • Server/tests/integration/test_middleware_auth_integration.py

@Scriptwonder Scriptwonder marked this pull request as ready for review July 5, 2026 05:30
Copilot AI review requested due to automatic review settings July 5, 2026 05:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens multi-Unity-Editor routing on the Python server to prevent stdio callers and concurrent local clients from silently targeting the wrong Unity project when multiple editors are connected (issue #1023).

Changes:

  • Stdio legacy routing now refuses to “guess” a target Unity instance when 2+ editors are connected and no instance is selected, instead raising an explicit selection-required error.
  • UnityInstanceMiddleware persistence is moved to FastMCP session-scoped state (per ctx.session_id) to prevent cross-client stomping of the active instance.
  • Tests are updated/added to lock in multi-instance refusal behavior and per-session isolation.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Server/src/transport/legacy/unity_connection.py Stops implicit “most-recent heartbeat” routing; raises on ambiguous multi-instance selection.
Server/src/transport/unity_instance_middleware.py Persists active instance via FastMCP session state instead of a process-global dict keyed by client_id.
Server/src/services/tools/set_active_instance.py Updates response payload to expose session_id and aligns storage with the new middleware persistence mechanism.
Server/src/services/tools/debug_request_context.py Removes enumeration of global active-instance state and reports session-scoped active instance only.
Server/tests/test_stdio_instance_resolution.py Adds regression tests for stdio instance resolution guard behavior in multi-instance scenarios.
Server/tests/test_transport_characterization.py Updates characterization tests to validate session-state-based persistence and session isolation for #1023.
Server/tests/integration/test_instance_routing_comprehensive.py Updates integration tests to use stateful context shims compatible with the new middleware persistence behavior.
Server/tests/integration/test_middleware_auth_integration.py Removes tests tied to the old get_session_key behavior that no longer applies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +557 to 567
# 2+ instances connected and nothing pinned. Refuse to guess —
# silently routing to the most-recently-heartbeated editor lets
# an unbound session retarget another project's Unity (#1023).
# Mirror the HTTP "multiple connected, no active set" guard.
available_ids = [inst.id for inst in instances]
raise ConnectionError(
"Multiple Unity instances are connected and none is selected. "
"Pass unity_instance on the call or use set_active_instance "
f"with one of: {available_ids}. "
"Read mcpforunity://instances for current sessions."
)
…, not retry

Live-testing follow-ups from the two-client isolation pass:

- The HTTP refusal only logged the available instances server-side while the
  stdio guard lists them inline — an agent hitting the HTTP path needed an
  extra mcpforunity://instances hop to act. InstanceSelectionRequiredError now
  carries available_instances structurally and appends them to the message,
  fetched from the registry at raise time (error path only).
- The transport wrapper's blanket except turned the refusal into hint="retry",
  which is misleading: a blind retry fails identically. Selection errors now
  return hint="select_instance" with reason and available_instances in data,
  so clients can route straight to set_active_instance.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Server/src/transport/plugin_hub.py (1)

927-965: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add available_instances to the post-loop ambiguity raises.
retry_on_reload=False skips the wait loop, so Server/src/transport/plugin_hub.py:960-965 still raises InstanceSelectionRequiredError without available_instances; HTTP callers then receive an empty instance list in data.available_instances.

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

In `@Server/src/transport/plugin_hub.py` around lines 927 - 965, The post-loop
ambiguity checks in the plugin session selection flow are still raising
InstanceSelectionRequiredError without available_instances, which causes HTTP
responses to lose the instance list. Update the final raise paths in the session
lookup logic in plugin_hub.py so they pass available_instances=await
_available_instance_ids(), matching the earlier ambiguity branches and
preserving the list for callers even when retry_on_reload is false.
🧹 Nitpick comments (1)
Server/src/transport/plugin_hub.py (1)

918-925: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Blind except Exception swallows unexpected registry errors silently.

Flagged by Ruff (BLE001). Since this is only used to enrich an already-erroring refusal path, at minimum log the exception so unexpected registry failures aren't invisible.

🔧 Proposed fix
         async def _available_instance_ids() -> list[str]:
             # Error path only; one extra registry read keeps the refusal actionable.
             try:
                 sessions = await cls._registry.list_sessions(user_id=user_id)
                 return sorted(
                     f"{s.project_name}@{s.project_hash}" for s in sessions.values())
-            except Exception:
+            except Exception:
+                logger.debug("Failed to compute available_instances for selection error", exc_info=True)
                 return []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Server/src/transport/plugin_hub.py` around lines 918 - 925, The
_available_instance_ids helper in plugin_hub.py is swallowing unexpected
registry failures with a bare except Exception, making registry issues
invisible. Update the _available_instance_ids function to catch the exception in
a named variable and log it through the existing logger or refusal-path logging
before returning an empty list, so cls._registry.list_sessions failures are
still observable while preserving the fallback behavior.

Source: Linters/SAST tools

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

Outside diff comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 927-965: The post-loop ambiguity checks in the plugin session
selection flow are still raising InstanceSelectionRequiredError without
available_instances, which causes HTTP responses to lose the instance list.
Update the final raise paths in the session lookup logic in plugin_hub.py so
they pass available_instances=await _available_instance_ids(), matching the
earlier ambiguity branches and preserving the list for callers even when
retry_on_reload is false.

---

Nitpick comments:
In `@Server/src/transport/plugin_hub.py`:
- Around line 918-925: The _available_instance_ids helper in plugin_hub.py is
swallowing unexpected registry failures with a bare except Exception, making
registry issues invisible. Update the _available_instance_ids function to catch
the exception in a named variable and log it through the existing logger or
refusal-path logging before returning an empty list, so
cls._registry.list_sessions failures are still observable while preserving the
fallback behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e36866d-496d-4a7b-a42e-5e74da197441

📥 Commits

Reviewing files that changed from the base of the PR and between eb67603 and e6ecad8.

📒 Files selected for processing (3)
  • Server/src/transport/plugin_hub.py
  • Server/src/transport/unity_transport.py
  • Server/tests/test_transport_characterization.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants