From d08fff1f933b28ec0a1919cf3250b0a8625c51f7 Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 21 Feb 2026 13:49:08 -0800 Subject: [PATCH 1/2] fix: strip MCP namespace prefix from tool names in batch_execute Clients may send prefixed tool names inside batch_execute commands (e.g. "UnityMCP:manage_gameobject" or "mcp__UnityMCP__manage_gameobject") which caused "Unknown or unsupported command type" errors since CommandRegistry registers handlers under short names only. Strips prefixes in both Python (server-side, early) and C# (Unity-side, defense-in-depth) to handle both colon and double-underscore formats. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchExecute.cs | 28 +++++++++- Server/src/services/tools/batch_execute.py | 19 +++++++ Server/tests/test_batch_strip_prefix.py | 25 +++++++++ .../Tools/BatchExecuteStripPrefixTests.cs | 52 +++++++++++++++++++ .../BatchExecuteStripPrefixTests.cs.meta | 11 ++++ 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 Server/tests/test_batch_strip_prefix.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs.meta diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index aca4a1994..798f51347 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -84,7 +84,7 @@ public static async Task HandleCommand(JObject @params) continue; } - string toolName = commandObj["tool"]?.ToString(); + string toolName = StripMcpPrefix(commandObj["tool"]?.ToString()); var rawParams = commandObj["params"] as JObject ?? new JObject(); var commandParams = NormalizeParameterKeys(rawParams); @@ -214,6 +214,32 @@ private static bool DetermineCallSucceeded(object result) return true; } + /// + /// Strip MCP server namespace prefix from a tool name. + /// Clients may send prefixed names like "UnityMCP:manage_gameobject" (colon format) + /// or "mcp__UnityMCP__manage_gameobject" (double-underscore, Claude Code style). + /// + internal static string StripMcpPrefix(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + return toolName; + + // Double-underscore format: "mcp__ServerName__tool_name" + if (toolName.StartsWith("mcp__")) + { + int secondSep = toolName.IndexOf("__", 5, StringComparison.Ordinal); + if (secondSep >= 0 && secondSep + 2 < toolName.Length) + return toolName.Substring(secondSep + 2); + } + + // Colon format: "ServerName:tool_name" + int colonIndex = toolName.LastIndexOf(':'); + if (colonIndex >= 0 && colonIndex + 1 < toolName.Length) + return toolName.Substring(colonIndex + 1); + + return toolName; + } + private static JObject NormalizeParameterKeys(JObject source) { if (source == null) diff --git a/Server/src/services/tools/batch_execute.py b/Server/src/services/tools/batch_execute.py index 3849dae2a..8542f5761 100644 --- a/Server/src/services/tools/batch_execute.py +++ b/Server/src/services/tools/batch_execute.py @@ -24,6 +24,23 @@ _cached_max_commands: int | None = None +def strip_mcp_prefix(tool_name: str) -> str: + """Strip MCP server namespace prefix from a tool name. + + Clients may send prefixed tool names like: + - "UnityMCP:manage_gameobject" (colon-separated) + - "mcp__UnityMCP__manage_gameobject" (double-underscore, Claude Code style) + This normalizes them to the short form ("manage_gameobject"). + """ + # Double-underscore format: "mcp__ServerName__tool_name" + if tool_name.startswith("mcp__") and "__" in tool_name[5:]: + return tool_name.split("__", 2)[-1] + # Colon format: "ServerName:tool_name" + if ":" in tool_name: + return tool_name.rsplit(":", 1)[-1] + return tool_name + + async def _get_max_commands_from_editor_state(ctx: Context) -> int: """ Attempt to read the configured batch limit from the Unity editor state. @@ -110,6 +127,8 @@ async def batch_execute( raise ValueError( f"Command at index {index} is missing a valid 'tool' name") + tool_name = strip_mcp_prefix(tool_name) + if params is None: params = {} if not isinstance(params, dict): diff --git a/Server/tests/test_batch_strip_prefix.py b/Server/tests/test_batch_strip_prefix.py new file mode 100644 index 000000000..29628c129 --- /dev/null +++ b/Server/tests/test_batch_strip_prefix.py @@ -0,0 +1,25 @@ +"""Tests for strip_mcp_prefix in batch_execute.""" + +import pytest +from services.tools.batch_execute import strip_mcp_prefix + + +@pytest.mark.parametrize( + "input_name,expected", + [ + # Plain names pass through unchanged + ("manage_gameobject", "manage_gameobject"), + ("batch_execute", "batch_execute"), + # Colon format (e.g. Claude Desktop) + ("UnityMCP:manage_gameobject", "manage_gameobject"), + ("unityMCP:batch_execute", "batch_execute"), + # Double-underscore format (e.g. Claude Code) + ("mcp__UnityMCP__manage_gameobject", "manage_gameobject"), + ("mcp__UnityMCP__batch_execute", "batch_execute"), + ("mcp__UnityMCP__manage_scene", "manage_scene"), + # Tool name itself contains underscores — should be preserved + ("mcp__UnityMCP__manage_scriptable_object", "manage_scriptable_object"), + ], +) +def test_strip_mcp_prefix(input_name: str, expected: str): + assert strip_mcp_prefix(input_name) == expected diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs new file mode 100644 index 000000000..5d64e3631 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs @@ -0,0 +1,52 @@ +using NUnit.Framework; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + public class BatchExecuteStripPrefixTests + { + [Test] + public void StripMcpPrefix_PlainName_ReturnsUnchanged() + { + Assert.AreEqual("manage_gameobject", BatchExecute.StripMcpPrefix("manage_gameobject")); + } + + [Test] + public void StripMcpPrefix_ColonFormat_StripsPrefix() + { + Assert.AreEqual("manage_gameobject", BatchExecute.StripMcpPrefix("UnityMCP:manage_gameobject")); + } + + [Test] + public void StripMcpPrefix_DoubleUnderscoreFormat_StripsPrefix() + { + Assert.AreEqual("manage_gameobject", BatchExecute.StripMcpPrefix("mcp__UnityMCP__manage_gameobject")); + } + + [Test] + public void StripMcpPrefix_DoubleUnderscore_ToolWithUnderscores_PreservesToolName() + { + Assert.AreEqual("batch_execute", BatchExecute.StripMcpPrefix("mcp__UnityMCP__batch_execute")); + } + + [Test] + public void StripMcpPrefix_NullOrEmpty_ReturnsSame() + { + Assert.IsNull(BatchExecute.StripMcpPrefix(null)); + Assert.AreEqual("", BatchExecute.StripMcpPrefix("")); + } + + [Test] + public void StripMcpPrefix_ColonOnly_PassesThrough() + { + // Edge case: ":" alone — no valid suffix, passes through (will fail at CommandRegistry) + Assert.AreEqual(":", BatchExecute.StripMcpPrefix(":")); + } + + [Test] + public void StripMcpPrefix_MultipleColons_UsesLast() + { + Assert.AreEqual("manage_gameobject", BatchExecute.StripMcpPrefix("a:b:manage_gameobject")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs.meta new file mode 100644 index 000000000..84e59077f --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteStripPrefixTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9424b9fc98b6cb947a6a6c7ffadc9d98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 9db0a25bc90eb7bdaac64fcf425030ea408250ef Mon Sep 17 00:00:00 2001 From: dsarno Date: Sat, 21 Feb 2026 14:02:31 -0800 Subject: [PATCH 2/2] fix: address PR review feedback for strip_mcp_prefix - Add StringComparison.Ordinal to StartsWith in C# (Sourcery) - Guard against empty suffix from trailing separators in Python to match C# semantics (CodeRabbit) - Add edge-case tests for ":", "Server:", "mcp__Server__", "" inputs Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchExecute.cs | 2 +- Server/src/services/tools/batch_execute.py | 8 ++++++-- Server/tests/test_batch_strip_prefix.py | 8 ++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index 798f51347..dbd1279ee 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -225,7 +225,7 @@ internal static string StripMcpPrefix(string toolName) return toolName; // Double-underscore format: "mcp__ServerName__tool_name" - if (toolName.StartsWith("mcp__")) + if (toolName.StartsWith("mcp__", StringComparison.Ordinal)) { int secondSep = toolName.IndexOf("__", 5, StringComparison.Ordinal); if (secondSep >= 0 && secondSep + 2 < toolName.Length) diff --git a/Server/src/services/tools/batch_execute.py b/Server/src/services/tools/batch_execute.py index 8542f5761..71f8d65e4 100644 --- a/Server/src/services/tools/batch_execute.py +++ b/Server/src/services/tools/batch_execute.py @@ -34,10 +34,14 @@ def strip_mcp_prefix(tool_name: str) -> str: """ # Double-underscore format: "mcp__ServerName__tool_name" if tool_name.startswith("mcp__") and "__" in tool_name[5:]: - return tool_name.split("__", 2)[-1] + suffix = tool_name.split("__", 2)[-1] + if suffix: + return suffix # Colon format: "ServerName:tool_name" if ":" in tool_name: - return tool_name.rsplit(":", 1)[-1] + suffix = tool_name.rsplit(":", 1)[-1] + if suffix: + return suffix return tool_name diff --git a/Server/tests/test_batch_strip_prefix.py b/Server/tests/test_batch_strip_prefix.py index 29628c129..019513e54 100644 --- a/Server/tests/test_batch_strip_prefix.py +++ b/Server/tests/test_batch_strip_prefix.py @@ -19,6 +19,14 @@ ("mcp__UnityMCP__manage_scene", "manage_scene"), # Tool name itself contains underscores — should be preserved ("mcp__UnityMCP__manage_scriptable_object", "manage_scriptable_object"), + # Edge cases: trailing separators pass through unchanged (match C# behavior) + (":", ":"), + ("Server:", "Server:"), + ("mcp__Server__", "mcp__Server__"), + # Empty string passes through + ("", ""), + # Multiple colons: last segment used + ("a:b:manage_gameobject", "manage_gameobject"), ], ) def test_strip_mcp_prefix(input_name: str, expected: str):