Skip to content

fix: enable project-scoped custom tools in stdio mode#1111

Open
Warlander wants to merge 5 commits intoCoplayDev:betafrom
Warlander:beta
Open

fix: enable project-scoped custom tools in stdio mode#1111
Warlander wants to merge 5 commits intoCoplayDev:betafrom
Warlander:beta

Conversation

@Warlander
Copy link
Copy Markdown

@Warlander Warlander commented May 5, 2026

Description

This PR enables project-scoped custom tools ([McpForUnityTool]) to work when using the stdio transport, not just HTTP Local. Previously, stdio mode had no way to discover or register project-specific custom tools.

Type of Change

Bug fix (non-breaking change that fixes an issue)

Changes Made

ToolDiscoveryService.cs - Added AppDomain fallback scan in ToolDiscoveryService when TypeCache misses project assemblies after domain reloads
StdioBridgeHost.cs - Heartbeat JSON now includes project_scoped_tools flag so the server knows when a Unity instance wants project-scoped tooling
models.py / port_discovery.py / main.py — UnityInstanceInfo gains project_scoped_tools; PortDiscovery parses it from status JSON; server auto-enables project-scoped tools on startup when a discovered instance requests it
custom_tool_service.py - CustomToolService now registers global custom tools even when project-scoped tools are enabled
v8_NEW_NETWORKING_SETUP.md - Updated migration notes to reflect that custom tools now work in both HTTP and stdio transports

Testing/Screenshots/Recordings

  • Custom tools annotated with [McpForUnityTool] appear in the MCP tool list when using stdio transport
  • No regression in HTTP Local transport custom tool discovery

Documentation Updates

  • I have added/removed/modified tools or resources
  • If yes, I have updated all documentation files using:
    • The LLM prompt at tools/UPDATE_DOCS_PROMPT.md (recommended)
    • Manual updates following the guide at tools/UPDATE_DOCS.md

(none of above apply)

Related Issues

Additional Notes

If you would like to see any changes to this PR, please let me know and I will get to it! :) My personal motivation behind this PR is, Kimi Code plays poorly with HTTP so I'm forced to use stdio with it, and having support for custom MCP's would be nice to have. Changes made by Kimi-k2.6 and human reviewed (and tested in actual project) by me to make sure they make sense and don't introduce possible vulnerabilities or other issues.

Summary by CodeRabbit

  • New Features

    • Project-scoped tools can be dynamically enabled from Unity instances via heartbeat signals.
    • Custom tools now work with both HTTP and stdio transports.
    • Per-project port discovery added for multi-project setups.
  • Improvements

    • More resilient tool discovery with a broader scan, fallback and per-assembly error handling.
    • Global tools are now registered alongside project-scoped tools.
  • Documentation

    • Docs updated to reflect cross-transport custom tool support.

Review Change Stack

Warlander added 4 commits May 5, 2026 22:06
- ToolDiscoveryService: add AppDomain fallback scan for [McpForUnityTool] types
- StdioBridgeHost: include project_scoped_tools flag in heartbeat JSON
- McpToolsSection: update tooltip and default to reflect stdio support
- models.py: add project_scoped_tools field to UnityInstanceInfo
- port_discovery.py: read project_scoped_tools from status JSON
- main.py: enable project-scoped tools when Unity instance requests it
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

Adds a project-scoped tools flag across editor/server (heartbeat, port discovery, UnityInstanceInfo), makes server startup honor explicit overrides or enablement by instances, strengthens editor tool discovery with TypeCache + AppDomain fallback and per-type error handling, and removes the global-tool registration early-return. UI tooltip and docs updated.

Changes

Tool Discovery & Project-Scoped Tools

Layer / File(s) Summary
Data Shape
Server/src/models/models.py
UnityInstanceInfo gains a new project_scoped_tools: bool = False field and to_dict() now serializes it.
Editor → Server Communication
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs, Server/src/transport/legacy/port_discovery.py
Heartbeat payload now includes project_scoped_tools from EditorPrefs; port discovery reads project_scoped_tools from status data and passes it into UnityInstanceInfo.
Tool Discovery Robustness
MCPForUnity/Editor/Services/ToolDiscoveryService.cs
Discovery now runs a primary TypeCache scan and an AppDomain reflection fallback that handles per-assembly reflection failures; results are merged, deduplicated, and each type's MCP attribute read is guarded with try/catch.
Server Initialization & Tool Registration
Server/src/main.py, Server/src/services/custom_tool_service.py
Startup accepts explicit CLI/env override for project_scoped_tools or enables it if any discovered instance requests it; custom_tool_service no longer early-returns and proceeds to register global tools unconditionally.
UI & Documentation
MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs, docs/migrations/v8_NEW_NETWORKING_SETUP.md
Tooltip updated to mention both HTTP Local and stdio transports; migration docs note custom tools work over both transports.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • CoplayDev/unity-mcp#735: Modifies ToolDiscoveryService; related to discovery fallback and preference initialization changes.
  • CoplayDev/unity-mcp#596: Implements project_scoped_tools wiring and editor/server prefs—overlaps feature and wiring.
  • CoplayDev/unity-mcp#636: Adds custom-tool discovery/execution flows and touches instance/tool metadata.

Poem

🐰 I sniffed the code, two paths I chose—
TypeCache first, AppDomain rose.
Heartbeats carry a tiny flag,
Servers listen, no more gag.
Tools now find their cozy home. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically describes the main change: enabling project-scoped custom tools in stdio mode, which is the primary objective of this PR.
Description check ✅ Passed The description covers all required template sections including changes made, type of change, testing, and documentation updates with specific file changes and implementation details.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

@Warlander Warlander changed the title Enable project-scoped custom tools in stdio mode fix: enable project-scoped custom tools in stdio mode May 5, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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)
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs (1)

1050-1066: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Heartbeat default contradicts UI toggle default for the same EditorPref.

StdioBridgeHost.WriteHeartbeat reads EditorPrefKeys.ProjectScopedToolsLocalHttp with default true, but McpToolsSection.RegisterCallbacks (Line 76–79) reads the same key with default false. On a fresh install (key absent):

  • The UI toggle visibly shows OFF.
  • The heartbeat reports project_scoped_tools=true.
  • Server/src/main.py (Line 896–909) sees the flag and auto-enables project-scoped mode without the user opting in.

Result: the server runs in project-scoped mode while the editor UI claims it's disabled, which is confusing and breaks the "Explicit CLI/env overrides always win; otherwise honor what Unity reports" contract.

Pick one default and use it in both places (and ideally read it through a shared helper).

🛠️ One-line alignment if "off by default" is the intended behavior
                 bool projectScopedTools = EditorPrefs.GetBool(
                     EditorPrefKeys.ProjectScopedToolsLocalHttp,
-                    true  // default to true so stdio behaves like HTTP Local by default
+                    false // must match McpToolsSection toggle default so UI and heartbeat agree
                 );

If "on by default" is preferred instead, flip falsetrue in McpToolsSection.cs Line 78 to match.

🤖 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 `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs` around
lines 1050 - 1066, StdioBridgeHost.WriteHeartbeat is reading
EditorPrefKeys.ProjectScopedToolsLocalHttp with default true while
McpToolsSection.RegisterCallbacks uses default false, causing inconsistent
behavior; fix by centralizing the preference read (e.g., add a helper like
GetProjectScopedToolsDefault or a static method on EditorPrefKeys) and use that
helper from both StdioBridgeHost.WriteHeartbeat and
McpToolsSection.RegisterCallbacks, or change the literal default in
StdioBridgeHost.WriteHeartbeat from true to false so both use the same default;
reference EditorPrefKeys.ProjectScopedToolsLocalHttp,
StdioBridgeHost.WriteHeartbeat, and McpToolsSection.RegisterCallbacks when
making the change.
🧹 Nitpick comments (2)
MCPForUnity/Editor/Services/ToolDiscoveryService.cs (1)

26-49: 💤 Low value

Two-phase scan with dedupe looks correct.

TypeCache results are unioned with an AppDomain reflection pass and deduped by Type identity, so post-domain-reload misses on project assemblies are now covered without double-registering. The per-assembly try/catch around GetTypes() is the right pattern for handling ReflectionTypeLoadException from third-party assemblies.

Minor: new Type[0] can be Array.Empty<Type>() to avoid an allocation per failing assembly.

🤖 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 `@MCPForUnity/Editor/Services/ToolDiscoveryService.cs` around lines 26 - 49,
Replace the allocation of an empty array inside the AppDomain reflection
fallback with a shared empty array to avoid per-assembly allocations: in the
lambda used after AppDomain.CurrentDomain.GetAssemblies() where you catch
exceptions from a.GetTypes() (in the code that calls
TypeCache.GetTypesWithAttribute<McpForUnityToolAttribute>() and then
SelectMany(...)), return Array.Empty<Type>() instead of new Type[0] in the catch
block.
Server/src/main.py (1)

893-912: 💤 Low value

Auto-detection works, but consider two follow-ups.

  1. Duplicate discovery work at startup. pool.discover_all_instances() here (Line 900) is called again inside server_lifespan (Line 200). Each call walks status files and TCP-probes every discovered port (_try_probe_unity_mcp with 0.3s timeout each). For a typical single-instance setup this is fine, but you could cache the result on the pool or pass it through the lifespan via the yielded dict to avoid the second probe pass.

  2. One-shot decision. project_scoped_tools is fixed for the life of the server based on what Unity reported at startup. If the user toggles the EditorPref afterwards, the server will not pick it up until restart. If that's by design, a brief log line documenting it (or a docstring note) would help future debugging.

Static-analysis BLE001 on Line 910 is acceptable here — the broad except is paired with logger.debug(..., exc_info=True) and a safe fallback to the explicit value, so no action needed.

🤖 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/main.py` around lines 893 - 912, The startup code calls
get_unity_connection_pool().discover_all_instances() to set
project_scoped_tools, which causes duplicate expensive probes because
server_lifespan also calls pool.discover_all_instances(); to fix, either cache
the discovery result on the pool object (e.g., set
pool._cached_discovered_instances after the first call and use it in subsequent
discover_all_instances calls) or pass the discovered instances through the
lifespan handshake (include the instances list in the dict yielded by
server_lifespan) so the second probe is avoided; also add a concise log or
docstring near project_scoped_tools / the startup block explaining this is a
one-shot decision (toggled Unity EditorPref after startup won’t change the
server’s behavior) so future debuggers aren’t surprised.
🤖 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 `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs`:
- Around line 1050-1066: StdioBridgeHost.WriteHeartbeat is reading
EditorPrefKeys.ProjectScopedToolsLocalHttp with default true while
McpToolsSection.RegisterCallbacks uses default false, causing inconsistent
behavior; fix by centralizing the preference read (e.g., add a helper like
GetProjectScopedToolsDefault or a static method on EditorPrefKeys) and use that
helper from both StdioBridgeHost.WriteHeartbeat and
McpToolsSection.RegisterCallbacks, or change the literal default in
StdioBridgeHost.WriteHeartbeat from true to false so both use the same default;
reference EditorPrefKeys.ProjectScopedToolsLocalHttp,
StdioBridgeHost.WriteHeartbeat, and McpToolsSection.RegisterCallbacks when
making the change.

---

Nitpick comments:
In `@MCPForUnity/Editor/Services/ToolDiscoveryService.cs`:
- Around line 26-49: Replace the allocation of an empty array inside the
AppDomain reflection fallback with a shared empty array to avoid per-assembly
allocations: in the lambda used after AppDomain.CurrentDomain.GetAssemblies()
where you catch exceptions from a.GetTypes() (in the code that calls
TypeCache.GetTypesWithAttribute<McpForUnityToolAttribute>() and then
SelectMany(...)), return Array.Empty<Type>() instead of new Type[0] in the catch
block.

In `@Server/src/main.py`:
- Around line 893-912: The startup code calls
get_unity_connection_pool().discover_all_instances() to set
project_scoped_tools, which causes duplicate expensive probes because
server_lifespan also calls pool.discover_all_instances(); to fix, either cache
the discovery result on the pool object (e.g., set
pool._cached_discovered_instances after the first call and use it in subsequent
discover_all_instances calls) or pass the discovered instances through the
lifespan handshake (include the instances list in the dict yielded by
server_lifespan) so the second probe is avoided; also add a concise log or
docstring near project_scoped_tools / the startup block explaining this is a
one-shot decision (toggled Unity EditorPref after startup won’t change the
server’s behavior) so future debuggers aren’t surprised.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29975faa-4cf0-437c-beba-ccfe05006e2d

📥 Commits

Reviewing files that changed from the base of the PR and between a2a5edf and bb17565.

📒 Files selected for processing (8)
  • MCPForUnity/Editor/Services/ToolDiscoveryService.cs
  • MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
  • MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs
  • Server/src/main.py
  • Server/src/models/models.py
  • Server/src/services/custom_tool_service.py
  • Server/src/transport/legacy/port_discovery.py
  • docs/migrations/v8_NEW_NETWORKING_SETUP.md

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs (1)

1050-1053: 💤 Low value

Consider centralizing the default value for project-scoped tools.

The default value false is documented to match the UI toggle default. To prevent drift and ensure consistency, consider extracting this default to a shared constant in EditorPrefKeys or a dedicated defaults class.

Example: Centralized default

In EditorPrefKeys.cs:

public const bool ProjectScopedToolsDefault = false;

Then use it here:

 bool projectScopedTools = EditorPrefs.GetBool(
     EditorPrefKeys.ProjectScopedToolsLocalHttp,
-    false // must match McpToolsSection toggle default so UI and heartbeat agree
+    EditorPrefKeys.ProjectScopedToolsDefault
 );
🤖 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 `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs` around
lines 1050 - 1053, Extract the literal default into a shared constant (e.g., add
public const bool ProjectScopedToolsDefault = false to EditorPrefKeys) and
replace the inline false in the EditorPrefs.GetBool call in
Transports/StdioBridgeHost.cs so the call becomes
EditorPrefs.GetBool(EditorPrefKeys.ProjectScopedToolsLocalHttp,
EditorPrefKeys.ProjectScopedToolsDefault); this centralizes the default and
keeps the UI/heartbeat default in sync.
🤖 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 `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs`:
- Around line 1050-1053: Extract the literal default into a shared constant
(e.g., add public const bool ProjectScopedToolsDefault = false to
EditorPrefKeys) and replace the inline false in the EditorPrefs.GetBool call in
Transports/StdioBridgeHost.cs so the call becomes
EditorPrefs.GetBool(EditorPrefKeys.ProjectScopedToolsLocalHttp,
EditorPrefKeys.ProjectScopedToolsDefault); this centralizes the default and
keeps the UI/heartbeat default in sync.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f13fa5c5-a08e-4b21-9a42-a0a39798d9ce

📥 Commits

Reviewing files that changed from the base of the PR and between bb17565 and f5de4ed.

📒 Files selected for processing (1)
  • MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants