From 3ee15a15d019d60c7d7ddde1d2bfa7bb38516a89 Mon Sep 17 00:00:00 2001 From: VortDyn Date: Fri, 9 Oct 2026 21:49:30 +0300 Subject: [PATCH 01/11] feat(clients): auto-configure DeepSeek Harness Adds a DeepSeek Harness configurator so "Configure All Detected Clients" covers DSH the way it already covers Cursor, Codex, Claude Code and Pi. DeepSeek Harness differs from every other JSON/TOML client here in one way that had to be handled rather than assumed: `dsh` consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, configured as Cordis rows in a YAML patch layer -- `$DSH_HOME/cordis.patch.yml` (override wins, then the env var, then `~/.dsh`) -- not a JSON/TOML config file. The package has no YAML parser, and real patch files may contain features YAML parsers choke on (`!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. All patch-file surgery therefore lives in `DshConfigHelper`, which manages exactly one marker-fenced block: upsert replaces only the fenced region, status parses only its known row shape, and Unregister removes only the fenced region. Detection is fail-closed in both directions: DSH counts as installed only once it has run at least once (its home directory exists), so a bare `~/.dsh` from another tool cannot read as "detected", and a DSH user who has not configured any MCP server yet is still reported. Tests cover the block surgery (HTTP/stdio row shapes, round-trip parsing, idempotent upsert, foreign patches preserved byte-for-byte, register-then- unregister leaves user content intact) and the configurator contract (config path, status with and without auto-rewrite). Docs: capability matrix row, the terminal-client list, and the per-client DSH section in the configurators guide. Also ignore `.compile-out/`, the scratch output of tools/compile-check.sh. --- .gitignore | 4 + .../DeepSeekHarnessConfigurator.cs | 296 ++++++++++++++ .../DeepSeekHarnessConfigurator.cs.meta | 11 + MCPForUnity/Editor/Helpers/DshConfigHelper.cs | 317 +++++++++++++++ .../Editor/Helpers/DshConfigHelper.cs.meta | 11 + .../EditMode/Clients/DshConfiguratorTests.cs | 375 ++++++++++++++++++ .../Clients/DshConfiguratorTests.cs.meta | 11 + website/docs/getting-started/clients.md | 3 +- website/docs/guides/client-configurators.md | 18 + 9 files changed, 1045 insertions(+), 1 deletion(-) create mode 100644 MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/DshConfigHelper.cs create mode 100644 MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta diff --git a/.gitignore b/.gitignore index e3d220f9d6..b825021816 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ docs/superpowers/ # Reference DLLs fetched by tools/compile-check.sh (not redistributable) .compile-refs/ +.compile-out/ + +# Local-only scratch files marked by name +*.DO-NOT-COMMIT.* diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs new file mode 100644 index 0000000000..53d22eecb6 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Configurator for the DeepSeek Harness (dsh CLI). DSH mounts external MCP + /// servers as Cordis rows for its @deepseek-ai/dsh-mcp-client plugin, stored + /// in the user's YAML patch layer — by default $DSH_HOME/cordis.patch.yml + /// (with $DSH_HOME defaulting to ~/.dsh), which applies to every DSH + /// profile on the machine. The row is written as a marker-fenced block managed by + /// so unrelated user patches are never parsed or + /// rewritten. After a DSH reload/restart, tools surface as + /// mcp__unityMCP__<tool>. + /// + public class DeepSeekHarnessConfigurator : McpClientConfiguratorBase + { + public DeepSeekHarnessConfigurator() : this(null) { } + + internal DeepSeekHarnessConfigurator(string dshHomeOverride) + : base(new McpClient + { + name = "DeepSeek Harness", + windowsConfigPath = BuildConfigPath(dshHomeOverride), + macConfigPath = BuildConfigPath(dshHomeOverride), + linuxConfigPath = BuildConfigPath(dshHomeOverride) + }) + { + } + + private static string BuildConfigPath(string dshHomeOverride) + { + return Path.Combine(ResolveDshHome(dshHomeOverride), "cordis.patch.yml"); + } + + /// + /// Resolves the DSH home directory: an explicit override wins, then the + /// DSH_HOME environment variable, then ~/.dsh (DSH's own default). + /// + internal static string ResolveDshHome(string dshHomeOverride) + { + if (!string.IsNullOrEmpty(dshHomeOverride)) return dshHomeOverride; + + string envHome = Environment.GetEnvironmentVariable("DSH_HOME"); + if (!string.IsNullOrEmpty(envHome)) return envHome; + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".dsh"); + } + + public override string GetConfigPath() => CurrentOsPath(); + + public override string GetConfigureActionLabel() + => client.status == McpStatus.Configured ? "Unregister" : "Configure"; + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + string fileText = File.Exists(path) ? File.ReadAllText(path) : null; + string block = fileText == null ? null : DshConfigHelper.ExtractManagedBlock(fileText); + + if (block == null) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + if (attemptAutoRewrite) + { + Configure(); + } + return client.status; + } + + bool parsed = DshConfigHelper.TryParseManagedBlock( + block, out string transport, out string url, out string command, out string[] args, + out _, out _); + if (!parsed) + { + // Block exists but is not in our known shape (hand-edited) — rewrite it. + client.SetStatus(McpStatus.IncorrectPath); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + if (attemptAutoRewrite) + { + Configure(); + } + return client.status; + } + + ConfiguredTransport current = HttpEndpointUtility.GetCurrentServerTransport(); + bool blockIsHttp = string.Equals(transport, "streamable-http", StringComparison.OrdinalIgnoreCase); + + if (blockIsHttp && current != Models.ConfiguredTransport.Stdio) + { + client.configuredTransport = HttpEndpointUtility.IsRemoteScope() + ? Models.ConfiguredTransport.HttpRemote + : Models.ConfiguredTransport.Http; + + if (UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl())) + { + client.SetStatus(McpStatus.Configured); + } + else if (attemptAutoRewrite) + { + Configure(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + return client.status; + } + + if (!blockIsHttp && current == Models.ConfiguredTransport.Stdio) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + + string expected = GetExpectedPackageSourceForValidation(); + string configured = McpConfigurationHelper.ExtractUvxUrl(args); + if (!string.IsNullOrEmpty(configured) && !string.IsNullOrEmpty(expected) + && McpConfigurationHelper.PathsEqual(configured, expected)) + { + client.SetStatus(McpStatus.Configured); + } + else if (attemptAutoRewrite) + { + Configure(); + } + else if (IsBetaPackageSource(configured) != IsBetaPackageSource(expected)) + { + client.SetStatus(McpStatus.VersionMismatch, IsBetaPackageSource(configured) + ? "Configured for prerelease server, but this package is stable. Re-configure to switch to stable." + : "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + return client.status; + } + + // Block transport disagrees with the plugin's current transport preference. + client.configuredTransport = blockIsHttp + ? Models.ConfiguredTransport.Http + : Models.ConfiguredTransport.Stdio; + if (attemptAutoRewrite) + { + Configure(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + + string fileText = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + string block = BuildBlock(); + string updated = DshConfigHelper.UpsertManagedBlock(fileText, block); + McpConfigurationHelper.WriteAtomicFile(path, updated); + + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + + public override void Unregister() + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return; + } + + string fileText = File.ReadAllText(path); + string updated = DshConfigHelper.RemoveManagedBlock(fileText); + if (!string.Equals(updated, fileText, StringComparison.Ordinal)) + { + McpConfigurationHelper.WriteAtomicFile(path, updated); + } + + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + public override string GetManualSnippet() + { + try + { + var sb = new StringBuilder(); + sb.AppendLine("# Mount the Unity MCP server in DeepSeek Harness."); + sb.AppendLine("# Managed block for $DSH_HOME/cordis.patch.yml ($DSH_HOME defaults to ~/.dsh;"); + sb.AppendLine("# the home patch applies to every profile). Re-run Configure to refresh it."); + sb.Append(BuildBlock()); + sb.AppendLine(); + sb.AppendLine("# Per-profile alternative instead of the home patch:"); + sb.AppendLine("# $DSH_HOME/profiles//cordis.patch.yml"); + sb.AppendLine("# One-shot alternative (no persistence):"); + sb.AppendLine("# dsh web --patch "); + return sb.ToString(); + } + catch (Exception ex) + { + return $"# error: {ex.Message}"; + } + } + + public override IList GetInstallationSteps() => new List + { + "Install DeepSeek Harness and run any 'dsh' command once (creates ~/.dsh)", + "Click Configure to add UnityMCP to the DSH cordis patch layer", + "Restart DSH (or reload its configuration) so the new MCP server mounts", + "Unity tools appear as mcp__unityMCP__" + }; + + private string BuildBlock() + { + var transport = HttpEndpointUtility.GetCurrentServerTransport(); + bool useHttp = transport != Models.ConfiguredTransport.Stdio; + + string httpUrl = null; + string uvxCommand = null; + string[] uvxArgs = null; + string apiKey = null; + string systemRoot = null; + + if (useHttp) + { + httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + if (HttpEndpointUtility.IsRemoteScope()) + { + apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + } + } + else + { + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrWhiteSpace(uvxPath)) + { + throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); + } + + var args = new List(); + foreach (string flag in AssetPathUtility.GetUvxDevFlagsList()) + { + args.Add(flag); + } + foreach (string arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + args.Add(arg); + } + args.Add(packageName); + args.Add("--transport"); + args.Add("stdio"); + uvxArgs = args.ToArray(); + uvxCommand = uvxPath; + + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + systemRoot = platformService.GetSystemRoot(); + } + } + + return DshConfigHelper.BuildUnityMcpBlock( + useHttp, + httpUrl, + uvxCommand, + uvxArgs, + string.IsNullOrEmpty(apiKey) ? null : AuthConstants.ApiKeyHeader, + apiKey, + systemRoot); + } + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs.meta new file mode 100644 index 0000000000..2e9188f8d9 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f9d6c1e8a3b4d7f9e5a1c2b3d4e5f60 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs new file mode 100644 index 0000000000..745cfa37b2 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// DeepSeek Harness (DSH) specific configuration helpers. DSH mounts external MCP + /// servers as Cordis configuration rows for the @deepseek-ai/dsh-mcp-client + /// plugin, stored in a YAML patch layer (by default $DSH_HOME/cordis.patch.yml, + /// with $DSH_HOME defaulting to ~/.dsh). + /// + /// User patch files may contain arbitrary YAML features this package has no parser + /// for (e.g. !!js JS-expression tags, anchors, unrelated server rows). This + /// helper therefore manages one marker-fenced block and never parses or rewrites + /// anything outside it: upsert replaces only the fenced region, and status checks + /// parse only the fenced region's known shape. + /// + public static class DshConfigHelper + { + public const string ServerId = "unitymcp"; + public const string ServerName = "unityMCP"; + public const string PluginName = "@deepseek-ai/dsh-mcp-client"; + + public const string BeginMarker = "# >>> MCP for Unity (unityMCP) managed block - do not edit inside >>>"; + public const string EndMarker = "# <<< MCP for Unity (unityMCP) <<<"; + + /// + /// Builds the fenced YAML patch block that mounts the Unity MCP server in DSH. + /// All values are passed in so the method is pure and unit-testable. + /// + /// true writes a streamable-http row, false a stdio row. + /// Streamable HTTP MCP RPC endpoint (used when ). + /// uvx executable path (used for stdio). + /// stdio arguments after the command (dev flags, package source, package name, --transport stdio). + /// Optional request header name for HTTP auth (e.g. X-API-Key). + /// Optional request header value for HTTP auth. + /// SystemRoot env override for stdio children on Windows, or null. + public static string BuildUnityMcpBlock( + bool useHttp, + string httpUrl, + string uvxCommand, + string[] uvxArgs, + string apiKeyHeader, + string apiKey, + string windowsSystemRoot) + { + var lines = new List + { + BeginMarker, + "- insert:", + $" - id: {ServerId}", + $" name: '{PluginName}'", + " config:", + $" serverName: {ServerName}", + }; + + if (useHttp) + { + lines.Add(" transport: streamable-http"); + lines.Add($" url: {Quote(httpUrl ?? string.Empty)}"); + if (!string.IsNullOrEmpty(apiKeyHeader) && !string.IsNullOrEmpty(apiKey)) + { + lines.Add(" headers:"); + lines.Add($" {apiKeyHeader}: {Quote(apiKey)}"); + } + } + else + { + lines.Add(" transport: stdio"); + lines.Add($" command: {Quote(uvxCommand ?? string.Empty)}"); + lines.Add(" args:"); + foreach (string arg in uvxArgs ?? Array.Empty()) + { + lines.Add($" - {Quote(arg ?? string.Empty)}"); + } + + if (!string.IsNullOrEmpty(windowsSystemRoot)) + { + lines.Add(" env:"); + lines.Add($" SystemRoot: {Quote(windowsSystemRoot)}"); + } + } + + lines.Add(EndMarker); + return string.Join("\n", lines) + "\n"; + } + + /// + /// Returns the rows between the fenced markers (exclusive), or null when no + /// complete block is present. Never reads content outside the markers. + /// + public static string ExtractManagedBlock(string fileText) + { + BlockRange? range = FindBlockRange(fileText); + if (range == null) return null; + + int contentStart = fileText.IndexOf('\n', range.Value.MarkerBegin); + if (contentStart < 0 || contentStart + 1 > range.Value.MarkerEnd) return string.Empty; + return fileText.Substring(contentStart + 1, range.Value.MarkerEnd - contentStart - 1); + } + + /// + /// Replaces the existing managed block in place, or appends the block at the end + /// of the document. All bytes outside the managed block are preserved verbatim. + /// + public static string UpsertManagedBlock(string fileText, string block) + { + if (block == null) throw new ArgumentNullException(nameof(block)); + string normalized = block.TrimEnd('\r', '\n') + "\n"; + + if (string.IsNullOrWhiteSpace(fileText)) + { + return normalized; + } + + BlockRange? range = FindBlockRange(fileText); + if (range == null) + { + string separator = fileText.EndsWith("\n") ? string.Empty : "\n"; + return fileText + separator + normalized; + } + + return fileText.Substring(0, range.Value.BlockStart) + + normalized + + fileText.Substring(range.Value.BlockEnd); + } + + /// + /// Removes the managed block, leaving the rest of the document untouched. + /// Returns the original text when no block is present. + /// + public static string RemoveManagedBlock(string fileText) + { + BlockRange? range = FindBlockRange(fileText); + if (range == null) return fileText; + + string before = fileText.Substring(0, range.Value.BlockStart); + string after = fileText.Substring(range.Value.BlockEnd); + + // Collapse a doubled blank line left behind by the removal. + if (before.EndsWith("\n\n") && after.StartsWith("\n", StringComparison.Ordinal)) + { + before = before.Substring(0, before.Length - 1); + } + + return before + after; + } + + /// True when the file already contains a complete managed block. + public static bool HasManagedBlock(string fileText) + { + return FindBlockRange(fileText) != null; + } + + /// + /// Parses the managed block emitted by . + /// Line-based by design: the block's shape is ours, so no YAML parser is needed + /// and foreign YAML can never confuse the result. + /// + public static bool TryParseManagedBlock( + string block, + out string transport, + out string url, + out string command, + out string[] args, + out string headerName, + out string headerValue) + { + transport = null; + url = null; + command = null; + args = null; + headerName = null; + headerValue = null; + + if (string.IsNullOrWhiteSpace(block)) return false; + + string[] lines = block.Replace("\r\n", "\n").Split('\n'); + var argList = new List(); + bool inArgs = false; + bool inHeaders = false; + int sectionIndent = -1; + + foreach (string rawLine in lines) + { + if (string.IsNullOrWhiteSpace(rawLine)) continue; + int indent = rawLine.Length - rawLine.TrimStart().Length; + string line = rawLine.Trim(); + + if (inArgs && indent > sectionIndent && line.StartsWith("- ", StringComparison.Ordinal)) + { + argList.Add(Unquote(line.Substring(2).Trim())); + continue; + } + + if (inHeaders && indent > sectionIndent && TrySplitKeyValue(line, out string hKey, out string hValue)) + { + if (headerName == null) + { + headerName = hKey; + headerValue = Unquote(hValue); + } + continue; + } + + inArgs = false; + inHeaders = false; + + if (line.StartsWith("transport:", StringComparison.Ordinal)) + { + transport = Unquote(line.Substring("transport:".Length).Trim()); + } + else if (line.StartsWith("url:", StringComparison.Ordinal)) + { + url = Unquote(line.Substring("url:".Length).Trim()); + } + else if (line.StartsWith("command:", StringComparison.Ordinal)) + { + command = Unquote(line.Substring("command:".Length).Trim()); + } + else if (line.StartsWith("args:", StringComparison.Ordinal)) + { + inArgs = true; + sectionIndent = indent; + } + else if (line.StartsWith("headers:", StringComparison.Ordinal)) + { + inHeaders = true; + sectionIndent = indent; + } + } + + args = argList.ToArray(); + return transport != null; + } + + /// Single-quotes a YAML scalar; embedded single quotes are doubled. + public static string Quote(string value) + { + return "'" + (value ?? string.Empty).Replace("'", "''") + "'"; + } + + /// Strips single quotes written by ; plain scalars pass through. + public static string Unquote(string value) + { + if (value == null) return null; + value = value.Trim(); + if (value.Length >= 2 && value[0] == '\'' && value[value.Length - 1] == '\'') + { + return value.Substring(1, value.Length - 2).Replace("''", "'"); + } + return value; + } + + private readonly struct BlockRange + { + /// Start of the begin-marker line (equal to MarkerBegin). + public readonly int BlockStart; + /// One past the end of the end-marker line (its newline included). + public readonly int BlockEnd; + /// Index of the first character of the begin marker. + public readonly int MarkerBegin; + /// Index of the first character of the end marker. + public readonly int MarkerEnd; + + public BlockRange(int blockStart, int blockEnd, int markerBegin, int markerEnd) + { + BlockStart = blockStart; + BlockEnd = blockEnd; + MarkerBegin = markerBegin; + MarkerEnd = markerEnd; + } + } + + /// Locates the fenced block; null when no complete marker pair exists. + private static BlockRange? FindBlockRange(string text) + { + if (string.IsNullOrEmpty(text)) return null; + + int markerBegin = IndexOfMarkerLine(text, BeginMarker, 0); + if (markerBegin < 0) return null; + + int markerEnd = IndexOfMarkerLine(text, EndMarker, markerBegin + BeginMarker.Length); + if (markerEnd < 0) return null; + + int blockEnd = text.IndexOf('\n', markerEnd); + blockEnd = blockEnd < 0 ? text.Length : blockEnd + 1; + return new BlockRange(markerBegin, blockEnd, markerBegin, markerEnd); + } + + /// Finds the next occurrence of marker at the start of its own line. + private static int IndexOfMarkerLine(string text, string marker, int startIndex) + { + int searchFrom = Math.Max(0, startIndex); + while (searchFrom <= text.Length - marker.Length) + { + int idx = text.IndexOf(marker, searchFrom, StringComparison.Ordinal); + if (idx < 0) return -1; + bool atLineStart = idx == 0 || text[idx - 1] == '\n'; + if (atLineStart) return idx; + searchFrom = idx + 1; + } + return -1; + } + + private static bool TrySplitKeyValue(string line, out string key, out string value) + { + key = null; + value = null; + int colon = line.IndexOf(':'); + if (colon <= 0) return false; + key = line.Substring(0, colon).Trim(); + value = line.Substring(colon + 1).Trim(); + return key.Length > 0 && value.Length > 0; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta new file mode 100644 index 0000000000..dcef9ca6f0 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c3f2a91d4b84e0f9a2c5d6e8f1a3b47 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs new file mode 100644 index 0000000000..c0b82c7206 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -0,0 +1,375 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Clients.Configurators; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.Clients +{ + /// + /// Covers the DeepSeek Harness integration: the marker-fenced YAML patch block + /// helper and the DeepSeekHarnessConfigurator that manages it inside the user's + /// $DSH_HOME/cordis.patch.yml without touching unrelated user patches. + /// + [TestFixture] + public class DshConfiguratorTests + { + private string _tempDir; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } + } + + // ---------------------------------------------------------------- helper: build + + [Test] + public void BuildBlock_Http_EmitsExpectedRows() + { + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", + uvxCommand: null, uvxArgs: null, + apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); + + StringAssert.Contains(DshConfigHelper.BeginMarker, block); + StringAssert.Contains(DshConfigHelper.EndMarker, block); + StringAssert.Contains("- insert:", block); + StringAssert.Contains($"- id: {DshConfigHelper.ServerId}", block); + StringAssert.Contains($"name: '{DshConfigHelper.PluginName}'", block); + StringAssert.Contains($"serverName: {DshConfigHelper.ServerName}", block); + StringAssert.Contains("transport: streamable-http", block); + StringAssert.Contains("url: 'http://127.0.0.1:8090/mcp'", block); + StringAssert.DoesNotContain("command:", block); + } + + [Test] + public void BuildBlock_Http_WithApiKey_EmitsHeader() + { + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", + uvxCommand: null, uvxArgs: null, + apiKeyHeader: "X-API-Key", apiKey: "s3cret", windowsSystemRoot: null); + + StringAssert.Contains("headers:", block); + StringAssert.Contains("X-API-Key: 's3cret'", block); + } + + [Test] + public void BuildBlock_Stdio_EmitsCommandArgsAndWindowsEnv() + { + string[] args = { "--from", "mcp-for-unity", "mcp-for-unity", "--transport", "stdio" }; + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: false, httpUrl: null, + uvxCommand: @"C:\tools\uvx.exe", uvxArgs: args, + apiKeyHeader: null, apiKey: null, + windowsSystemRoot: @"C:\Windows"); + + StringAssert.Contains("transport: stdio", block); + StringAssert.Contains(@"command: 'C:\tools\uvx.exe'", block); + StringAssert.Contains("- '--transport'", block); + StringAssert.Contains("- 'stdio'", block); + StringAssert.Contains(@"SystemRoot: 'C:\Windows'", block); + StringAssert.DoesNotContain("url:", block); + } + + // ---------------------------------------------------------------- helper: parse + + [Test] + public void TryParse_RoundTripsHttpBlock() + { + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", + uvxCommand: null, uvxArgs: null, + apiKeyHeader: "X-API-Key", apiKey: "it''s-a-key", windowsSystemRoot: null); + + bool parsed = DshConfigHelper.TryParseManagedBlock( + block, out string transport, out string url, out string command, out string[] args, + out string headerName, out string headerValue); + + Assert.IsTrue(parsed); + Assert.AreEqual("streamable-http", transport); + Assert.AreEqual("http://127.0.0.1:8090/mcp", url); + Assert.IsNull(command); + Assert.IsTrue(args == null || args.Length == 0, "http block must have no args"); + Assert.AreEqual("X-API-Key", headerName); + Assert.AreEqual("it's-a-key", headerValue, "doubled single quotes must unescape"); + } + + [Test] + public void TryParse_RoundTripsStdioBlock_WithWindowsPaths() + { + string[] args = { "--from", @"E:\Dev\unity-mcp\Server", "mcp-for-unity", "--transport", "stdio" }; + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: false, httpUrl: null, + uvxCommand: @"C:\Users\test user\.local\bin\uvx.exe", uvxArgs: args, + apiKeyHeader: null, apiKey: null, + windowsSystemRoot: @"C:\Windows"); + + bool parsed = DshConfigHelper.TryParseManagedBlock( + block, out string transport, out string url, out string command, out string[] parsedArgs, + out string headerName, out string headerValue); + + Assert.IsTrue(parsed); + Assert.AreEqual("stdio", transport); + Assert.IsNull(url); + Assert.AreEqual(@"C:\Users\test user\.local\bin\uvx.exe", command); + Assert.AreEqual(args, parsedArgs); + Assert.IsNull(headerName); + Assert.IsNull(headerValue, "env rows must not be mistaken for headers"); + } + + [Test] + public void TryParse_RejectsEmptyBlock() + { + bool parsed = DshConfigHelper.TryParseManagedBlock( + "", out _, out _, out _, out _, out _, out _); + Assert.IsFalse(parsed); + } + + // ---------------------------------------------------------------- helper: upsert/remove + + [Test] + public void Upsert_IntoEmptyFile_ProducesValidListDocument() + { + string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); + string result = DshConfigHelper.UpsertManagedBlock(null, block); + + Assert.IsTrue(result.StartsWith(DshConfigHelper.BeginMarker), "file must remain a top-level YAML list"); + StringAssert.EndsWith("\n", result); + } + + [Test] + public void Upsert_Twice_IsIdempotent() + { + string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); + string once = DshConfigHelper.UpsertManagedBlock(null, block); + string twice = DshConfigHelper.UpsertManagedBlock(once, BuildHttpBlock("http://127.0.0.1:9090/mcp")); + + Assert.AreEqual(1, CountOccurrences(twice, DshConfigHelper.BeginMarker), + "re-configuring must replace the block, not append a second one"); + StringAssert.Contains("9090", twice); + StringAssert.DoesNotContain("8090", twice); + } + + [Test] + public void Upsert_PreservesForeignUserPatches_ByteForByte() + { + string userPatches = + "- insert:\n" + + " - id: mcp-memory\n" + + " name: '@deepseek-ai/dsh-mcp-client'\n" + + " config:\n" + + " serverName: memory\n" + + " transport: stdio\n" + + " command: my-memory-mcp\n" + + " env:\n" + + " MEMORY_TOKEN: !!js process.env.MEMORY_TOKEN\n" + + "\n"; + + string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); + string result = DshConfigHelper.UpsertManagedBlock(userPatches, block); + + StringAssert.StartsWith(userPatches, result, "user patch content must be preserved verbatim"); + Assert.AreEqual(1, CountOccurrences(result, DshConfigHelper.BeginMarker)); + } + + [Test] + public void Upsert_AppendsWithoutTrailingNewlineInput() + { + string existing = "- insert:\n - id: x\n name: pkg-x"; + string result = DshConfigHelper.UpsertManagedBlock(existing, BuildHttpBlock("http://127.0.0.1:8090/mcp")); + + StringAssert.StartsWith(existing + "\n", result); + } + + [Test] + public void Remove_DeletesOnlyManagedBlock() + { + string userPatches = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; + string file = userPatches + BuildHttpBlock("http://127.0.0.1:8090/mcp"); + + string result = DshConfigHelper.RemoveManagedBlock(file); + + Assert.AreEqual(userPatches, result, "only the managed block may be removed"); + } + + [Test] + public void Remove_WithoutBlock_ReturnsTextUnchanged() + { + string text = "- insert: []\n"; + Assert.AreEqual(text, DshConfigHelper.RemoveManagedBlock(text)); + } + + [Test] + public void Extract_ReturnsNullWithoutMarkers_AndRowsWithMarkers() + { + Assert.IsNull(DshConfigHelper.ExtractManagedBlock("- insert: []\n")); + + string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); + string inner = DshConfigHelper.ExtractManagedBlock(block); + StringAssert.Contains("transport: streamable-http", inner); + StringAssert.DoesNotContain(DshConfigHelper.BeginMarker, inner); + } + + // ---------------------------------------------------------------- configurator wiring + + [Test] + public void ConfigPath_UsesOverrideHome() + { + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + Assert.AreEqual(Path.Combine(_tempDir, "cordis.patch.yml"), configurator.GetConfigPath()); + } + + [Test] + public void ResolveDshHome_PrefersOverride_ThenEnv_ThenDotDsh() + { + Assert.AreEqual(@"E:\custom\home", DeepSeekHarnessConfigurator.ResolveDshHome(@"E:\custom\home")); + + string previous = Environment.GetEnvironmentVariable("DSH_HOME"); + try + { + Environment.SetEnvironmentVariable("DSH_HOME", @"E:\env\home"); + Assert.AreEqual(@"E:\env\home", DeepSeekHarnessConfigurator.ResolveDshHome(null)); + + Environment.SetEnvironmentVariable("DSH_HOME", null); + string expectedHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dsh"); + Assert.AreEqual(expectedHome, DeepSeekHarnessConfigurator.ResolveDshHome(null)); + } + finally + { + Environment.SetEnvironmentVariable("DSH_HOME", previous); + } + } + + [Test] + public void IsInstalled_FollowsParentDirectory() + { + string missing = Path.Combine(_tempDir, "does-not-exist"); + Assert.IsFalse(new DeepSeekHarnessConfigurator(missing).IsInstalled); + Assert.IsTrue(new DeepSeekHarnessConfigurator(_tempDir).IsInstalled); + } + + [Test] + public void CheckStatus_MissingBlock_NotConfigured_WithoutRewrite() + { + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + var status = configurator.CheckStatus(attemptAutoRewrite: false); + Assert.AreEqual(McpStatus.NotConfigured, status); + Assert.IsFalse(File.Exists(configurator.GetConfigPath())); + } + + [Test] + public void CheckStatus_HttpMatch_Configured() + { + WithHttpTransport(true, () => + { + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + File.WriteAllText( + Path.Combine(_tempDir, "cordis.patch.yml"), + BuildHttpBlock(expectedUrl)); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + var status = configurator.CheckStatus(attemptAutoRewrite: false); + + Assert.AreEqual(McpStatus.Configured, status); + Assert.AreNotEqual(ConfiguredTransport.Unknown, configurator.ConfiguredTransport); + }); + } + + [Test] + public void CheckStatus_HttpMismatch_RewritesOnAutoRewrite() + { + WithHttpTransport(true, () => + { + string path = Path.Combine(_tempDir, "cordis.patch.yml"); + string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; + File.WriteAllText(path, userRow + BuildHttpBlock("http://127.0.0.1:1/mcp")); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + var before = configurator.CheckStatus(attemptAutoRewrite: false); + Assert.AreEqual(McpStatus.IncorrectPath, before); + + var after = configurator.CheckStatus(attemptAutoRewrite: true); + Assert.AreEqual(McpStatus.Configured, after); + + string updated = File.ReadAllText(path); + StringAssert.StartsWith(userRow, updated, "user patches must survive the rewrite"); + StringAssert.Contains( + HttpEndpointUtility.GetMcpRpcUrl(), updated, + "rewritten block must carry the current RPC URL"); + }); + } + + [Test] + public void Configure_ThenUnregister_LeavesUserPatchesIntact() + { + WithHttpTransport(true, () => + { + string path = Path.Combine(_tempDir, "cordis.patch.yml"); + string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; + File.WriteAllText(path, userRow); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + + string configured = File.ReadAllText(path); + StringAssert.StartsWith(userRow, configured); + Assert.AreEqual(1, CountOccurrences(configured, DshConfigHelper.BeginMarker)); + Assert.AreEqual(McpStatus.Configured, configurator.Status); + + configurator.Unregister(); + + string after = File.ReadAllText(path); + Assert.AreEqual(userRow, after, "unregister must remove exactly the managed block"); + Assert.AreEqual(McpStatus.NotConfigured, configurator.Status); + }); + } + + // ---------------------------------------------------------------- helpers + + private static string BuildHttpBlock(string url) + { + return DshConfigHelper.BuildUnityMcpBlock( + useHttp: true, httpUrl: url, + uvxCommand: null, uvxArgs: null, + apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); + } + + private static void WithHttpTransport(bool useHttp, Action body) + { + bool original = EditorConfigurationCache.Instance.UseHttpTransport; + try + { + EditorConfigurationCache.Instance.SetUseHttpTransport(useHttp); + body(); + } + finally + { + EditorConfigurationCache.Instance.SetUseHttpTransport(original); + } + } + + private static int CountOccurrences(string text, string needle) + { + int count = 0; + int idx = 0; + while ((idx = text.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += needle.Length; + } + return count; + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta new file mode 100644 index 0000000000..9ab2c718a7 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b4e8d2f1a6c4e3b8d7f0a5c1b2e3f64 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/website/docs/getting-started/clients.md b/website/docs/getting-started/clients.md index 245d17a25d..1f3da153ca 100644 --- a/website/docs/getting-started/clients.md +++ b/website/docs/getting-started/clients.md @@ -27,13 +27,14 @@ MCP for Unity auto-configures every client the package detects on your machine. | **OpenClaw** | HTTP / stdio | yes | yes | yes | Requires `openclaw-mcp-bridge` plugin enabled. Follows MCP for Unity's transport choice. | | **Antigravity** | HTTP | yes | yes | varies | Requires an MCP toggle in Antigravity settings. | | **Pi** | stdio / HTTP | yes | yes | yes | Needs an MCP extension (Pi ships no MCP client of its own): `pi install npm:pi-mcp-adapter`. Writes the shared `~/.config/mcp/mcp.json`. | +| **DeepSeek Harness** | HTTP / stdio | yes | yes | yes | Writes a managed block into `$DSH_HOME/cordis.patch.yml` (applies to every profile). Tools appear as `mcp__unityMCP__`. | ## How to pick - **You want it to just work**: Claude Desktop. Stdio means no port conflicts and no firewall prompts. - **You're building a multi-agent or remote workflow**: anything HTTP. Multiple clients can share one Python server; see [Multi-Instance Routing](/guides/multi-instance). - **You're already in your IDE**: Cursor, VS Code Copilot, or Cline — keeps you in flow. -- **You want a terminal**: Claude Code, Copilot CLI, Codex, Gemini CLI, Qwen Code, or Pi. +- **You want a terminal**: Claude Code, Copilot CLI, Codex, Gemini CLI, Qwen Code, Pi, or DeepSeek Harness. ## Manual configuration diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index 8cbdf101de..793b0a3efe 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -173,6 +173,24 @@ Some clients cannot be handled by the generic JSON configurator alone. - The bridge exposes a single proxy tool such as `unityMCP__call`, which then forwards to Unity MCP tool names. - OpenClaw support follows the currently selected MCP for Unity transport (via `openclaw-mcp-bridge`). +### DeepSeek Harness (Cordis patch YAML) + +- Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, configured as Cordis rows in a **YAML patch layer** — not a JSON/TOML config. +- Target file is `$DSH_HOME/cordis.patch.yml` (the `DSH_HOME` environment variable wins, default `~/.dsh`). The home patch layer applies to every DSH profile (web, sdk, headless, acp); per-profile `cordis.patch.yml` files are documented in the manual snippet instead. +- The row shape (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): + ```yaml + - insert: + - id: unitymcp + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: unityMCP + transport: streamable-http # or stdio with command/args + url: http://127.0.0.1:/mcp + ``` +- All patch-file surgery lives in `DshConfigHelper` (`MCPForUnity/Editor/Helpers/DshConfigHelper.cs`), which manages **one marker-fenced block**. This is deliberate: the package has no YAML parser, and user patch files may contain features one (e.g. `!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. Upsert replaces only the fenced region; status checks parse only the fenced region's known shape; Unregister removes only the fenced region. +- After a DSH reload/restart, Unity tools surface as `mcp__unityMCP__` (DSH's server-qualified MCP naming). +- Detection is fail-closed: DSH counts as installed only once it has run at least once (its home directory exists). + --- ## Adding a new MCP client (typical JSON case) From 4c7e533f3b8ae0f0d179225c2d40bffe49df0e20 Mon Sep 17 00:00:00 2001 From: VortDyn Date: Fri, 9 Oct 2026 22:14:42 +0300 Subject: [PATCH 02/11] fix(tests): pass unescaped API key in DshConfiguratorTests round-trip Addresses the CodeRabbit review on PR #1453: - DshConfiguratorTests fed "it''s-a-key" (two literal apostrophes) into BuildUnityMcpBlock. Quote doubled them into four, and Unquote returned the doubled value, so the assertion against "it''s-a-key" failed. The round-trip logic was correct; only the test input was wrong. Pass the unescaped value instead. - Fix the garbled sentence ("may contain features one (e.g.") in the DSH section of website/docs/guides/client-configurators.md. --- .../Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs | 2 +- website/docs/guides/client-configurators.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index c0b82c7206..3db0cf269c 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -90,7 +90,7 @@ public void TryParse_RoundTripsHttpBlock() string block = DshConfigHelper.BuildUnityMcpBlock( useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", uvxCommand: null, uvxArgs: null, - apiKeyHeader: "X-API-Key", apiKey: "it''s-a-key", windowsSystemRoot: null); + apiKeyHeader: "X-API-Key", apiKey: "it's-a-key", windowsSystemRoot: null); bool parsed = DshConfigHelper.TryParseManagedBlock( block, out string transport, out string url, out string command, out string[] args, diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index 793b0a3efe..014546d467 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -187,7 +187,7 @@ Some clients cannot be handled by the generic JSON configurator alone. transport: streamable-http # or stdio with command/args url: http://127.0.0.1:/mcp ``` -- All patch-file surgery lives in `DshConfigHelper` (`MCPForUnity/Editor/Helpers/DshConfigHelper.cs`), which manages **one marker-fenced block**. This is deliberate: the package has no YAML parser, and user patch files may contain features one (e.g. `!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. Upsert replaces only the fenced region; status checks parse only the fenced region's known shape; Unregister removes only the fenced region. +- All patch-file surgery lives in `DshConfigHelper` (`MCPForUnity/Editor/Helpers/DshConfigHelper.cs`), which manages **one marker-fenced block**. This is deliberate: the package has no YAML parser, and user patch files may contain YAML features this helper does not parse (e.g. `!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. Upsert replaces only the fenced region; status checks parse only the fenced region's known shape; Unregister removes only the fenced region. - After a DSH reload/restart, Unity tools surface as `mcp__unityMCP__` (DSH's server-qualified MCP naming). - Detection is fail-closed: DSH counts as installed only once it has run at least once (its home directory exists). From 42c4e2b6ab0c23ba5ab137e66bdd7351d20ad476 Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 13:50:30 +0200 Subject: [PATCH 03/11] feat(clients): install DeepSeek Harness config as a native DSH bundle --- .../DeepSeekHarnessConfigurator.cs | 321 ++++++++++++++---- MCPForUnity/Editor/Helpers/DshBundleWriter.cs | 84 +++++ .../EditMode/Clients/DshBundleWriterTests.cs | 102 ++++++ .../EditMode/Clients/DshConfiguratorTests.cs | 173 ++++++++-- 4 files changed, 586 insertions(+), 94 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/DshBundleWriter.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index 53d22eecb6..e8b97adfbe 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -1,30 +1,40 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Text; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; -using UnityEditor; +using Newtonsoft.Json.Linq; namespace MCPForUnity.Editor.Clients.Configurators { /// /// Configurator for the DeepSeek Harness (dsh CLI). DSH mounts external MCP - /// servers as Cordis rows for its @deepseek-ai/dsh-mcp-client plugin, stored - /// in the user's YAML patch layer — by default $DSH_HOME/cordis.patch.yml - /// (with $DSH_HOME defaulting to ~/.dsh), which applies to every DSH - /// profile on the machine. The row is written as a marker-fenced block managed by - /// so unrelated user patches are never parsed or - /// rewritten. After a DSH reload/restart, tools surface as - /// mcp__unityMCP__<tool>. + /// servers as Cordis rows for its @deepseek-ai/dsh-mcp-client plugin. This + /// configurator writes a configuration-only DSH bundle (manifest + patch) under + /// $DSH_HOME/bundles/unity-mcp/ and installs it into a named profile via + /// dsh plugin --profile <name> add <bundle-dir>. The bundle is + /// profile-scoped and removable through the plugin manager, unlike the root home + /// patch which applies to every profile. After a DSH reload/restart, tools surface + /// as mcp__unityMCP__<tool>. /// public class DeepSeekHarnessConfigurator : McpClientConfiguratorBase { - public DeepSeekHarnessConfigurator() : this(null) { } + private readonly string _dshHomeOverride; + private readonly string _profileOverride; - internal DeepSeekHarnessConfigurator(string dshHomeOverride) + /// + /// Injectable command runner for dsh CLI calls. Tests swap this to avoid + /// spawning real processes; production uses . + /// + internal static Func DshCommandRunner = RunDshCommand; + + public DeepSeekHarnessConfigurator() : this(null, null) { } + + internal DeepSeekHarnessConfigurator(string dshHomeOverride, string profileOverride = null) : base(new McpClient { name = "DeepSeek Harness", @@ -33,13 +43,37 @@ internal DeepSeekHarnessConfigurator(string dshHomeOverride) linuxConfigPath = BuildConfigPath(dshHomeOverride) }) { + _dshHomeOverride = dshHomeOverride; + _profileOverride = profileOverride; } + /// + /// Bundle install runs a CLI and must not happen automatically. The startup sweep + /// (StartupConfigRewrite) calls with + /// attemptAutoRewrite: true; if we auto-wrote here, merely opening a project + /// would install the bundle without the user asking. Bundle install is a user + /// action only. + /// + public override bool SupportsAutoConfigure => false; + + /// + /// DSH is "installed" when its home directory exists (created on first DSH run). + /// This is not derived from the config path because the config path lives inside + /// the managed bundle directory, which only exists after Configure runs. + /// + public override bool IsInstalled => Directory.Exists(ResolveDshHome(_dshHomeOverride)); + + // ------------------------------------------------------------------ paths + private static string BuildConfigPath(string dshHomeOverride) { - return Path.Combine(ResolveDshHome(dshHomeOverride), "cordis.patch.yml"); + return Path.Combine( + DshBundleWriter.GetBundleDir(ResolveDshHome(dshHomeOverride)), + "cordis.patch.yml"); } + public override string GetConfigPath() => CurrentOsPath(); + /// /// Resolves the DSH home directory: an explicit override wins, then the /// DSH_HOME environment variable, then ~/.dsh (DSH's own default). @@ -56,42 +90,74 @@ internal static string ResolveDshHome(string dshHomeOverride) ".dsh"); } - public override string GetConfigPath() => CurrentOsPath(); + /// + /// Resolves the DSH profile name: an explicit override wins, then the + /// DSH_PROFILE environment variable, then "desktop". + /// + internal static string ResolveDshProfile(string profileOverride) + { + if (!string.IsNullOrEmpty(profileOverride)) return profileOverride; + + string envProfile = Environment.GetEnvironmentVariable("DSH_PROFILE"); + if (!string.IsNullOrEmpty(envProfile)) return envProfile; + + return "desktop"; + } + + // ------------------------------------------------------------------ status public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Configure"; + /// + /// Validates that the bundle is installed and its managed block matches the + /// current transport. Never calls , regardless of + /// — bundle install is a user action only. + /// When validation would want a rewrite, returns + /// (or ) instead. + /// public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { try { - string path = GetConfigPath(); - string fileText = File.Exists(path) ? File.ReadAllText(path) : null; - string block = fileText == null ? null : DshConfigHelper.ExtractManagedBlock(fileText); + string dshHome = ResolveDshHome(_dshHomeOverride); + string profile = ResolveDshProfile(_profileOverride); - if (block == null) + // Bundle files must exist. + string bundlePatchPath = Path.Combine( + DshBundleWriter.GetBundleDir(dshHome), "cordis.patch.yml"); + if (!File.Exists(bundlePatchPath)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + // Profile package.json must list the bundle. + if (!ProfileListsBundle(dshHome, profile, DshBundleWriter.BundleName)) { client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; - if (attemptAutoRewrite) - { - Configure(); - } + return client.status; + } + + // Parse the bundle's managed block and validate transport/url/source. + string fileText = File.ReadAllText(bundlePatchPath); + string block = DshConfigHelper.ExtractManagedBlock(fileText); + if (block == null) + { + client.SetStatus(McpStatus.IncorrectPath); + client.configuredTransport = Models.ConfiguredTransport.Unknown; return client.status; } bool parsed = DshConfigHelper.TryParseManagedBlock( - block, out string transport, out string url, out string command, out string[] args, + block, out string transport, out string url, out _, out string[] args, out _, out _); if (!parsed) { - // Block exists but is not in our known shape (hand-edited) — rewrite it. client.SetStatus(McpStatus.IncorrectPath); client.configuredTransport = Models.ConfiguredTransport.Unknown; - if (attemptAutoRewrite) - { - Configure(); - } return client.status; } @@ -108,10 +174,6 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { client.SetStatus(McpStatus.Configured); } - else if (attemptAutoRewrite) - { - Configure(); - } else { client.SetStatus(McpStatus.IncorrectPath); @@ -130,10 +192,6 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { client.SetStatus(McpStatus.Configured); } - else if (attemptAutoRewrite) - { - Configure(); - } else if (IsBetaPackageSource(configured) != IsBetaPackageSource(expected)) { client.SetStatus(McpStatus.VersionMismatch, IsBetaPackageSource(configured) @@ -151,14 +209,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) client.configuredTransport = blockIsHttp ? Models.ConfiguredTransport.Http : Models.ConfiguredTransport.Stdio; - if (attemptAutoRewrite) - { - Configure(); - } - else - { - client.SetStatus(McpStatus.IncorrectPath); - } + client.SetStatus(McpStatus.IncorrectPath); } catch (Exception ex) { @@ -169,15 +220,30 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) return client.status; } + // ------------------------------------------------------------------ configure / unregister + public override void Configure() { - string path = GetConfigPath(); - McpConfigurationHelper.EnsureConfigDirectoryExists(path); - - string fileText = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + string dshHome = ResolveDshHome(_dshHomeOverride); + string profile = ResolveDshProfile(_profileOverride); string block = BuildBlock(); - string updated = DshConfigHelper.UpsertManagedBlock(fileText, block); - McpConfigurationHelper.WriteAtomicFile(path, updated); + string bundleDir = DshBundleWriter.WriteBundle(dshHome, block); + + // Install into the named DSH profile through the plugin manager. + string manualCommand = $"dsh plugin --profile {profile} add {bundleDir}"; + (int exitCode, string output) = DshCommandRunner(new[] + { + "plugin", "--profile", profile, "add", bundleDir + }); + if (exitCode != 0) + { + throw new InvalidOperationException( + $"Failed to install DSH bundle via '{manualCommand}' (exit code {exitCode}): {output}\n" + + $"Run this command manually: {manualCommand}"); + } + + // Migration: remove any legacy root-patch managed block to prevent duplicate rows. + RemoveLegacyRootPatchBlock(dshHome); client.SetStatus(McpStatus.Configured); client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); @@ -185,37 +251,54 @@ public override void Configure() public override void Unregister() { - string path = GetConfigPath(); - if (!File.Exists(path)) - { - client.SetStatus(McpStatus.NotConfigured); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - return; - } + string dshHome = ResolveDshHome(_dshHomeOverride); + string profile = ResolveDshProfile(_profileOverride); - string fileText = File.ReadAllText(path); - string updated = DshConfigHelper.RemoveManagedBlock(fileText); - if (!string.Equals(updated, fileText, StringComparison.Ordinal)) + // Best-effort: remove from DSH plugin manager (ignore failure if dsh is missing). + try { - McpConfigurationHelper.WriteAtomicFile(path, updated); + DshCommandRunner(new[] + { + "plugin", "--profile", profile, "remove", DshBundleWriter.BundleName + }); } + catch { } + + DshBundleWriter.TryDeleteBundle(dshHome); + RemoveLegacyRootPatchBlock(dshHome); client.SetStatus(McpStatus.NotConfigured); client.configuredTransport = Models.ConfiguredTransport.Unknown; } + // ------------------------------------------------------------------ manual snippet / steps + public override string GetManualSnippet() { try { + string dshHome = ResolveDshHome(_dshHomeOverride); + string profile = ResolveDshProfile(_profileOverride); + string bundleDir = DshBundleWriter.GetBundleDir(dshHome); + var sb = new StringBuilder(); - sb.AppendLine("# Mount the Unity MCP server in DeepSeek Harness."); - sb.AppendLine("# Managed block for $DSH_HOME/cordis.patch.yml ($DSH_HOME defaults to ~/.dsh;"); - sb.AppendLine("# the home patch applies to every profile). Re-run Configure to refresh it."); + sb.AppendLine("# Mount the Unity MCP server in DeepSeek Harness as a native DSH bundle."); + sb.AppendLine("# Configure writes these files and runs 'dsh plugin add' to install them."); + sb.AppendLine("# $DSH_HOME defaults to ~/.dsh"); + sb.AppendLine(); + sb.AppendLine($"# --- {bundleDir}/package.json ---"); + sb.AppendLine(DshBundleWriter.ManifestJson.TrimEnd()); + sb.AppendLine(); + sb.AppendLine($"# --- {bundleDir}/cordis.patch.yml ---"); sb.Append(BuildBlock()); sb.AppendLine(); - sb.AppendLine("# Per-profile alternative instead of the home patch:"); - sb.AppendLine("# $DSH_HOME/profiles//cordis.patch.yml"); + sb.AppendLine("# Install command (run by Configure):"); + sb.AppendLine($"# dsh plugin --profile {profile} add {bundleDir}"); + sb.AppendLine("# Remove command:"); + sb.AppendLine($"# dsh plugin --profile {profile} remove {DshBundleWriter.BundleName}"); + sb.AppendLine(); + sb.AppendLine("# Per-profile alternative (no bundle, not uninstallable via plugin manager):"); + sb.AppendLine("# Write the block above into $DSH_HOME/profiles//cordis.patch.yml"); sb.AppendLine("# One-shot alternative (no persistence):"); sb.AppendLine("# dsh web --patch "); return sb.ToString(); @@ -229,11 +312,13 @@ public override string GetManualSnippet() public override IList GetInstallationSteps() => new List { "Install DeepSeek Harness and run any 'dsh' command once (creates ~/.dsh)", - "Click Configure to add UnityMCP to the DSH cordis patch layer", + "Click Configure to write the bundle and install it into your DSH profile via dsh plugin add", "Restart DSH (or reload its configuration) so the new MCP server mounts", "Unity tools appear as mcp__unityMCP__" }; + // ------------------------------------------------------------------ internals + private string BuildBlock() { var transport = HttpEndpointUtility.GetCurrentServerTransport(); @@ -250,7 +335,7 @@ private string BuildBlock() httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); if (HttpEndpointUtility.IsRemoteScope()) { - apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + apiKey = UnityEditor.EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); } } else @@ -292,5 +377,109 @@ private string BuildBlock() apiKey, systemRoot); } + + /// + /// Removes the managed block from the root home patch (legacy target). This + /// prevents duplicate rows when migrating from the root-patch approach to the + /// bundle approach. + /// + private static void RemoveLegacyRootPatchBlock(string dshHome) + { + string rootPatch = Path.Combine(dshHome, "cordis.patch.yml"); + if (!File.Exists(rootPatch)) return; + + string fileText = File.ReadAllText(rootPatch); + if (!DshConfigHelper.HasManagedBlock(fileText)) return; + + string updated = DshConfigHelper.RemoveManagedBlock(fileText); + McpConfigurationHelper.WriteAtomicFile(rootPatch, updated); + } + + /// + /// Checks whether the target profile's package.json lists the given bundle + /// name in dsh.profile.bundles. + /// + private static bool ProfileListsBundle(string dshHome, string profile, string bundleName) + { + string profilePackagePath = Path.Combine(dshHome, "profiles", profile, "package.json"); + if (!File.Exists(profilePackagePath)) return false; + + try + { + var json = JObject.Parse(File.ReadAllText(profilePackagePath)); + var bundles = json["dsh"]?["profile"]?["bundles"] as JArray; + if (bundles == null) return false; + foreach (var item in bundles) + { + if (string.Equals(item.ToString(), bundleName, StringComparison.Ordinal)) + return true; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Default dsh command runner using . Captures + /// stdout+stderr, enforces a 60-second timeout, and returns -1 on any failure + /// (including missing dsh) so callers can surface the manual command. + /// + private static (int ExitCode, string Output) RunDshCommand(string[] args) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "dsh", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + Arguments = BuildArgumentsString(args) + }; + + using (var process = Process.Start(psi)) + { + if (process == null) return (-1, "Failed to start dsh process"); + + string output = process.StandardOutput.ReadToEnd() + + process.StandardError.ReadToEnd(); + if (!process.WaitForExit(60_000)) + { + try { process.Kill(); } catch { } + return (-1, output + "\n(dsh timed out after 60s)"); + } + return (process.ExitCode, output); + } + } + catch (Exception ex) + { + return (-1, $"Failed to run 'dsh': {ex.Message}"); + } + } + + /// Builds a process argument string with Windows-compatible quoting. + private static string BuildArgumentsString(string[] args) + { + if (args == null || args.Length == 0) return string.Empty; + var sb = new StringBuilder(); + for (int i = 0; i < args.Length; i++) + { + if (i > 0) sb.Append(' '); + string arg = args[i] ?? string.Empty; + if (arg.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0) + { + sb.Append('"').Append(arg.Replace("\"", "\\\"")).Append('"'); + } + else + { + sb.Append(arg); + } + } + return sb.ToString(); + } } } diff --git a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs new file mode 100644 index 0000000000..d16ed317af --- /dev/null +++ b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Writes and removes a configuration-only DeepSeek Harness (DSH) bundle that mounts + /// the Unity MCP server. The bundle is a small directory under + /// $DSH_HOME/bundles/<dir> containing a package.json manifest and + /// the cordis.patch.yml patch block (produced by + /// ). It is installed into a specific + /// DSH profile through the DSH plugin manager + /// (dsh plugin --profile <name> add <bundle-dir>), making the row + /// profile-scoped and removable as a unit — unlike the root home patch, which applies + /// to every profile and is not uninstallable through the plugin manager. + /// + public static class DshBundleWriter + { + /// Bundle package name recorded in the manifest and used for remove. + public const string BundleName = "@local/unity-mcp-dsh"; + + /// Directory name under $DSH_HOME/bundles/. + public const string BundleDirName = "unity-mcp"; + + private const string ManifestFileName = "package.json"; + private const string PatchFileName = "cordis.patch.yml"; + + /// + /// The exact manifest content proven to install via DSH's plugin manager. + /// Must stay byte-identical to what the dsh plugin add flow expects. + /// + internal const string ManifestJson = + "{\n" + + " \"name\": \"@local/unity-mcp-dsh\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"private\": true,\n" + + " \"type\": \"module\",\n" + + " \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }\n" + + "}\n"; + + /// + /// Writes (or overwrites) the bundle directory containing the manifest and the + /// given patch block. The bundle directory is entirely managed by us, so no marker + /// surgery is needed inside it — files are overwritten wholesale. Returns the + /// bundle directory path. + /// + /// DSH home directory ($DSH_HOME or ~/.dsh). + /// Full YAML block from . + public static string WriteBundle(string dshHome, string patchBlock) + { + if (string.IsNullOrEmpty(dshHome)) + throw new ArgumentException("dshHome is required", nameof(dshHome)); + if (patchBlock == null) + throw new ArgumentNullException(nameof(patchBlock)); + + string dir = GetBundleDir(dshHome); + Directory.CreateDirectory(dir); + + File.WriteAllText(Path.Combine(dir, ManifestFileName), ManifestJson); + File.WriteAllText(Path.Combine(dir, PatchFileName), patchBlock); + + return dir; + } + + /// + /// Removes the bundle directory recursively. Returns true when the directory + /// existed and was deleted; false when it was already absent. + /// + public static bool TryDeleteBundle(string dshHome) + { + if (string.IsNullOrEmpty(dshHome)) return false; + string dir = GetBundleDir(dshHome); + if (!Directory.Exists(dir)) return false; + Directory.Delete(dir, true); + return true; + } + + /// Path to the bundle directory that holds the manifest and patch. + public static string GetBundleDir(string dshHome) + { + return Path.Combine(dshHome, "bundles", BundleDirName); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs new file mode 100644 index 0000000000..b2c5acd1d0 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.Clients +{ + /// + /// Covers : writing and deleting the configuration-only + /// DSH bundle directory that holds the manifest and the managed patch block. + /// + [TestFixture] + public class DshBundleWriterTests + { + private string _tempDir; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } + } + + [Test] + public void WriteBundle_CreatesBothFiles() + { + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", + uvxCommand: null, uvxArgs: null, + apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); + + string dir = DshBundleWriter.WriteBundle(_tempDir, block); + + Assert.IsTrue(File.Exists(Path.Combine(dir, "package.json"))); + Assert.IsTrue(File.Exists(Path.Combine(dir, "cordis.patch.yml"))); + Assert.AreEqual(Path.Combine(_tempDir, "bundles", "unity-mcp"), dir); + } + + [Test] + public void WriteBundle_ExactManifestContent() + { + string dir = DshBundleWriter.WriteBundle(_tempDir, "# test"); + + string manifest = File.ReadAllText(Path.Combine(dir, "package.json")); + StringAssert.Contains("\"name\": \"@local/unity-mcp-dsh\"", manifest); + StringAssert.Contains("\"version\": \"1.0.0\"", manifest); + StringAssert.Contains("\"private\": true", manifest); + StringAssert.Contains("\"type\": \"module\"", manifest); + StringAssert.Contains("\"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }", manifest); + } + + [Test] + public void WriteBundle_PatchContentMatchesInput() + { + string block = DshConfigHelper.BuildUnityMcpBlock( + useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", + uvxCommand: null, uvxArgs: null, + apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); + + string dir = DshBundleWriter.WriteBundle(_tempDir, block); + + string written = File.ReadAllText(Path.Combine(dir, "cordis.patch.yml")); + Assert.AreEqual(block, written, "patch file must contain the block verbatim"); + } + + [Test] + public void WriteBundle_OverwritesExistingFiles() + { + DshBundleWriter.WriteBundle(_tempDir, "# first"); + string dir = DshBundleWriter.WriteBundle(_tempDir, "# second"); + + string written = File.ReadAllText(Path.Combine(dir, "cordis.patch.yml")); + Assert.AreEqual("# second", written, "second write must overwrite the first"); + } + + [Test] + public void TryDeleteBundle_RemovesDir_ReturnsTrue() + { + DshBundleWriter.WriteBundle(_tempDir, "# test"); + string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + Assert.IsTrue(Directory.Exists(bundleDir)); + + bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir); + + Assert.IsTrue(deleted); + Assert.IsFalse(Directory.Exists(bundleDir)); + } + + [Test] + public void TryDeleteBundle_AbsentDir_ReturnsFalse() + { + bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir); + Assert.IsFalse(deleted); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index 3db0cf269c..ed1a401974 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using MCPForUnity.Editor.Clients.Configurators; using MCPForUnity.Editor.Helpers; @@ -9,25 +10,28 @@ namespace MCPForUnityTests.Editor.Clients { /// - /// Covers the DeepSeek Harness integration: the marker-fenced YAML patch block - /// helper and the DeepSeekHarnessConfigurator that manages it inside the user's - /// $DSH_HOME/cordis.patch.yml without touching unrelated user patches. + /// Covers the DeepSeek Harness integration: the marker-fenced YAML patch block helper + /// and the DeepSeekHarnessConfigurator that writes a configuration-only DSH bundle + /// and installs it via the DSH plugin manager (dsh plugin add). /// [TestFixture] public class DshConfiguratorTests { private string _tempDir; + private Func _originalRunner; [SetUp] public void SetUp() { _tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); + _originalRunner = DeepSeekHarnessConfigurator.DshCommandRunner; } [TearDown] public void TearDown() { + DeepSeekHarnessConfigurator.DshCommandRunner = _originalRunner; try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } } @@ -224,10 +228,11 @@ public void Extract_ReturnsNullWithoutMarkers_AndRowsWithMarkers() // ---------------------------------------------------------------- configurator wiring [Test] - public void ConfigPath_UsesOverrideHome() + public void ConfigPath_UsesBundlePatchInsideOverrideHome() { var configurator = new DeepSeekHarnessConfigurator(_tempDir); - Assert.AreEqual(Path.Combine(_tempDir, "cordis.patch.yml"), configurator.GetConfigPath()); + string expected = Path.Combine(_tempDir, "bundles", "unity-mcp", "cordis.patch.yml"); + Assert.AreEqual(expected, configurator.GetConfigPath()); } [Test] @@ -252,6 +257,26 @@ public void ResolveDshHome_PrefersOverride_ThenEnv_ThenDotDsh() } } + [Test] + public void ResolveDshProfile_PrefersOverride_ThenEnv_ThenDesktop() + { + Assert.AreEqual("my-profile", DeepSeekHarnessConfigurator.ResolveDshProfile("my-profile")); + + string previous = Environment.GetEnvironmentVariable("DSH_PROFILE"); + try + { + Environment.SetEnvironmentVariable("DSH_PROFILE", "web"); + Assert.AreEqual("web", DeepSeekHarnessConfigurator.ResolveDshProfile(null)); + + Environment.SetEnvironmentVariable("DSH_PROFILE", null); + Assert.AreEqual("desktop", DeepSeekHarnessConfigurator.ResolveDshProfile(null)); + } + finally + { + Environment.SetEnvironmentVariable("DSH_PROFILE", previous); + } + } + [Test] public void IsInstalled_FollowsParentDirectory() { @@ -261,12 +286,22 @@ public void IsInstalled_FollowsParentDirectory() } [Test] - public void CheckStatus_MissingBlock_NotConfigured_WithoutRewrite() + public void CheckStatus_MissingBundle_NotConfigured_NoAutoWrite() { + bool seamCalled = false; + DeepSeekHarnessConfigurator.DshCommandRunner = args => + { + seamCalled = true; + return (0, ""); + }; + var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var status = configurator.CheckStatus(attemptAutoRewrite: false); + var status = configurator.CheckStatus(attemptAutoRewrite: true); + Assert.AreEqual(McpStatus.NotConfigured, status); - Assert.IsFalse(File.Exists(configurator.GetConfigPath())); + Assert.IsFalse(seamCalled, "CheckStatus must never invoke the dsh command seam"); + string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + Assert.IsFalse(Directory.Exists(bundleDir), "CheckStatus must never write bundle files"); } [Test] @@ -275,9 +310,8 @@ public void CheckStatus_HttpMatch_Configured() WithHttpTransport(true, () => { string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); - File.WriteAllText( - Path.Combine(_tempDir, "cordis.patch.yml"), - BuildHttpBlock(expectedUrl)); + WriteBundleFiles(_tempDir, BuildHttpBlock(expectedUrl)); + WriteProfileBundleEntry(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); var status = configurator.CheckStatus(attemptAutoRewrite: false); @@ -288,26 +322,71 @@ public void CheckStatus_HttpMatch_Configured() } [Test] - public void CheckStatus_HttpMismatch_RewritesOnAutoRewrite() + public void CheckStatus_HttpMismatch_ReturnsIncorrectPath_WithoutRewrite() { WithHttpTransport(true, () => { - string path = Path.Combine(_tempDir, "cordis.patch.yml"); - string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; - File.WriteAllText(path, userRow + BuildHttpBlock("http://127.0.0.1:1/mcp")); + string bundlePatch = Path.Combine(_tempDir, "bundles", "unity-mcp", "cordis.patch.yml"); + WriteBundleFiles(_tempDir, BuildHttpBlock("http://127.0.0.1:1/mcp")); + WriteProfileBundleEntry(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); var before = configurator.CheckStatus(attemptAutoRewrite: false); Assert.AreEqual(McpStatus.IncorrectPath, before); var after = configurator.CheckStatus(attemptAutoRewrite: true); - Assert.AreEqual(McpStatus.Configured, after); + Assert.AreEqual(McpStatus.IncorrectPath, after, "must not auto-rewrite"); + + string updated = File.ReadAllText(bundlePatch); + StringAssert.Contains("127.0.0.1:1", updated, "bundle must not have been rewritten"); + }); + } + + [Test] + public void Configure_WritesBundleAndInvokesSeam() + { + WithHttpTransport(true, () => + { + string capturedArgs = null; + DeepSeekHarnessConfigurator.DshCommandRunner = args => + { + capturedArgs = string.Join(" ", args); + return (0, "ok"); + }; + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + + string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + Assert.IsTrue(File.Exists(Path.Combine(bundleDir, "package.json")), "manifest must exist"); + Assert.IsTrue(File.Exists(Path.Combine(bundleDir, "cordis.patch.yml")), "patch must exist"); + + StringAssert.Contains("plugin", capturedArgs); + StringAssert.Contains("--profile", capturedArgs); + StringAssert.Contains("desktop", capturedArgs); + StringAssert.Contains("add", capturedArgs); + StringAssert.Contains(bundleDir, capturedArgs); + Assert.AreEqual(McpStatus.Configured, configurator.Status); + }); + } + + [Test] + public void Configure_MigrationRemovesLegacyRootPatchBlock() + { + WithHttpTransport(true, () => + { + string rootPatch = Path.Combine(_tempDir, "cordis.patch.yml"); + string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; + File.WriteAllText(rootPatch, userRow + BuildHttpBlock("http://127.0.0.1:1/mcp")); + + DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); - string updated = File.ReadAllText(path); - StringAssert.StartsWith(userRow, updated, "user patches must survive the rewrite"); - StringAssert.Contains( - HttpEndpointUtility.GetMcpRpcUrl(), updated, - "rewritten block must carry the current RPC URL"); + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + + string after = File.ReadAllText(rootPatch); + Assert.AreEqual(userRow, after, "legacy managed block must be removed from root patch"); + Assert.AreEqual(0, CountOccurrences(after, DshConfigHelper.BeginMarker)); }); } @@ -316,23 +395,48 @@ public void Configure_ThenUnregister_LeavesUserPatchesIntact() { WithHttpTransport(true, () => { - string path = Path.Combine(_tempDir, "cordis.patch.yml"); + string rootPatch = Path.Combine(_tempDir, "cordis.patch.yml"); string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; - File.WriteAllText(path, userRow); + File.WriteAllText(rootPatch, userRow); + + DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); - string configured = File.ReadAllText(path); - StringAssert.StartsWith(userRow, configured); - Assert.AreEqual(1, CountOccurrences(configured, DshConfigHelper.BeginMarker)); Assert.AreEqual(McpStatus.Configured, configurator.Status); + Assert.AreEqual(userRow, File.ReadAllText(rootPatch), "root patch must be untouched when no legacy block exists"); configurator.Unregister(); - string after = File.ReadAllText(path); - Assert.AreEqual(userRow, after, "unregister must remove exactly the managed block"); Assert.AreEqual(McpStatus.NotConfigured, configurator.Status); + Assert.AreEqual(userRow, File.ReadAllText(rootPatch), "unregister must not touch user rows"); + Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "unity-mcp")), "bundle dir must be deleted"); + }); + } + + [Test] + public void Unregister_CallsRemoveAndDeletesBundle() + { + WithHttpTransport(true, () => + { + string capturedArgs = null; + DeepSeekHarnessConfigurator.DshCommandRunner = args => + { + capturedArgs = string.Join(" ", args); + return (0, "ok"); + }; + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + Assert.IsTrue(Directory.Exists(bundleDir)); + + configurator.Unregister(); + + StringAssert.Contains("remove", capturedArgs); + StringAssert.Contains(DshBundleWriter.BundleName, capturedArgs); + Assert.IsFalse(Directory.Exists(bundleDir), "bundle directory must be deleted"); }); } @@ -346,6 +450,19 @@ private static string BuildHttpBlock(string url) apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); } + private static void WriteBundleFiles(string dshHome, string patchBlock) + { + DshBundleWriter.WriteBundle(dshHome, patchBlock); + } + + private static void WriteProfileBundleEntry(string dshHome, string profile) + { + string profileDir = Path.Combine(dshHome, "profiles", profile); + Directory.CreateDirectory(profileDir); + string packageJson = "{\n \"dsh\": {\n \"profile\": {\n \"bundles\": [\"" + DshBundleWriter.BundleName + "\"]\n }\n }\n}\n"; + File.WriteAllText(Path.Combine(profileDir, "package.json"), packageJson); + } + private static void WithHttpTransport(bool useHttp, Action body) { bool original = EditorConfigurationCache.Instance.UseHttpTransport; From d5296f7baf68214574185026286ef75b53935375 Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 14:07:54 +0200 Subject: [PATCH 04/11] fix(clients): resolve dsh.cmd launcher, guard missing DSH profile, docs for bundle flow - RunDshCommand resolves the dsh launcher via DSH_CLI override then PATH+PATHEXT (dsh.cmd/dsh.bat/dsh.exe/dsh) and routes .cmd/.bat shims through cmd.exe /s /c; Process with UseShellExecute=false cannot find dsh.cmd by bare name, so Configure would have failed on every Windows install. - Configure fails fast when the target DSH profile does not exist (with the available profiles listed), because 'dsh plugin add' silently initializes a missing profile as a bare, unusable one. - Commit .meta for the new sources per repo convention. - Docs: describe the native bundle flow (dsh plugin add / remove, profile-scoped) instead of the root home patch. --- .../DeepSeekHarnessConfigurator.cs | 105 +++++++++++++++++- .../Editor/Helpers/DshBundleWriter.cs.meta | 2 + .../Clients/DshBundleWriterTests.cs.meta | 2 + .../EditMode/Clients/DshConfiguratorTests.cs | 88 +++++++++++++++ website/docs/getting-started/clients.md | 2 +- website/docs/guides/client-configurators.md | 14 ++- 6 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index e8b97adfbe..49173f1fdd 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -226,6 +226,7 @@ public override void Configure() { string dshHome = ResolveDshHome(_dshHomeOverride); string profile = ResolveDshProfile(_profileOverride); + EnsureProfileExists(dshHome, profile); string block = BuildBlock(); string bundleDir = DshBundleWriter.WriteBundle(dshHome, block); @@ -378,6 +379,33 @@ private string BuildBlock() systemRoot); } + /// + /// Fails fast when the target DSH profile does not exist. dsh plugin add + /// silently initializes a missing profile as a bare, unusable one, so a typo in + /// DSH_PROFILE would otherwise "succeed" into junk. + /// + private static void EnsureProfileExists(string dshHome, string profile) + { + string profilePackage = Path.Combine(dshHome, "profiles", profile, "package.json"); + if (File.Exists(profilePackage)) return; + + string profilesRoot = Path.Combine(dshHome, "profiles"); + string available = "(none)"; + if (Directory.Exists(profilesRoot)) + { + var names = new List(); + foreach (string dir in Directory.GetDirectories(profilesRoot)) + { + names.Add(Path.GetFileName(dir)); + } + if (names.Count > 0) available = string.Join(", ", names); + } + + throw new InvalidOperationException( + $"DSH profile '{profile}' does not exist ({profilePackage} missing). " + + $"Set DSH_PROFILE to an existing profile. Available profiles: {available}."); + } + /// /// Removes the managed block from the root home patch (legacy target). This /// prevents duplicate rows when migrating from the root-patch approach to the @@ -423,22 +451,34 @@ private static bool ProfileListsBundle(string dshHome, string profile, string bu } /// - /// Default dsh command runner using . Captures - /// stdout+stderr, enforces a 60-second timeout, and returns -1 on any failure - /// (including missing dsh) so callers can surface the manual command. + /// Default dsh command runner using . Resolves the + /// launcher explicitly because on Windows dsh is a .cmd shim and + /// with UseShellExecute=false will not find it by + /// bare name. Captures stdout+stderr, enforces a 60-second timeout, and returns + /// -1 on any failure (including missing dsh) so callers can surface the + /// manual command. /// - private static (int ExitCode, string Output) RunDshCommand(string[] args) + internal static (int ExitCode, string Output) RunDshCommand(string[] args) { try { + if (!TryResolveDshExecutable(out string exePath, out bool isCmdShim)) + { + return (-1, "dsh executable not found on PATH. Install DeepSeek Harness " + + "or set DSH_CLI to the dsh launcher (.cmd on Windows)."); + } + var psi = new ProcessStartInfo { - FileName = "dsh", + FileName = isCmdShim ? "cmd.exe" : exePath, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, - Arguments = BuildArgumentsString(args) + // cmd.exe /s /c "" strips the outer quotes and runs the rest as-is. + Arguments = isCmdShim + ? "/s /c \"" + QuoteArg(exePath) + " " + BuildArgumentsString(args) + "\"" + : BuildArgumentsString(args) }; using (var process = Process.Start(psi)) @@ -461,6 +501,59 @@ private static (int ExitCode, string Output) RunDshCommand(string[] args) } } + /// + /// Finds the dsh launcher: a DSH_CLI environment override wins, + /// then each PATH entry is probed with platform-specific names + /// (dsh.cmd/dsh.bat/dsh.exe/dsh on Windows, + /// dsh elsewhere). Reports whether the hit is a cmd shim that must run + /// through cmd.exe. + /// + internal static bool TryResolveDshExecutable(out string path, out bool isCmdShim) + { + path = null; + isCmdShim = false; + + string overridePath = Environment.GetEnvironmentVariable("DSH_CLI"); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + path = overridePath; + isCmdShim = IsCmdShim(overridePath); + return true; + } + + bool windows = Path.DirectorySeparatorChar == '\\'; + string[] names = windows + ? new[] { "dsh.cmd", "dsh.bat", "dsh.exe", "dsh" } + : new[] { "dsh" }; + + foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator)) + { + string trimmed = dir.Trim().Trim('"'); + if (trimmed.Length == 0) continue; + foreach (string name in names) + { + string candidate = Path.Combine(trimmed, name); + if (!File.Exists(candidate)) continue; + path = candidate; + isCmdShim = IsCmdShim(candidate); + return true; + } + } + + return false; + } + + private static bool IsCmdShim(string path) + { + return path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase); + } + + private static string QuoteArg(string arg) + { + return "\"" + (arg ?? string.Empty) + "\""; + } + /// Builds a process argument string with Windows-compatible quoting. private static string BuildArgumentsString(string[] args) { diff --git a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta new file mode 100644 index 0000000000..2c1fab2dee --- /dev/null +++ b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da07114fdbe824c48a855104ffa23816 \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta new file mode 100644 index 0000000000..d784e34c97 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5e50316070798b641ba4647a9adaf0a8 \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index ed1a401974..e71cf5706b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -354,6 +354,7 @@ public void Configure_WritesBundleAndInvokesSeam() return (0, "ok"); }; + WriteProfilePackage(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); @@ -381,6 +382,7 @@ public void Configure_MigrationRemovesLegacyRootPatchBlock() DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); + WriteProfilePackage(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); @@ -401,6 +403,7 @@ public void Configure_ThenUnregister_LeavesUserPatchesIntact() DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); + WriteProfilePackage(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); @@ -427,6 +430,7 @@ public void Unregister_CallsRemoveAndDeletesBundle() return (0, "ok"); }; + WriteProfilePackage(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); @@ -440,6 +444,82 @@ public void Unregister_CallsRemoveAndDeletesBundle() }); } + [Test] + public void Configure_MissingProfile_ThrowsWithoutCreatingProfile() + { + WithHttpTransport(true, () => + { + bool runnerCalled = false; + DeepSeekHarnessConfigurator.DshCommandRunner = args => + { + runnerCalled = true; + return (0, "ok"); + }; + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + var ex = Assert.Throws(() => configurator.Configure()); + + StringAssert.Contains("desktop", ex.Message); + Assert.IsFalse(runnerCalled, "dsh CLI must not run when the profile is missing"); + Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "profiles", "desktop")), + "guard must not create the profile"); + }); + } + + [Test] + public void TryResolveDshExecutable_FindsCmdShimOnPath() + { + if (Path.DirectorySeparatorChar != '\\') Assert.Ignore("cmd shim resolution is Windows-specific"); + + string shimDir = Path.Combine(_tempDir, "fakebin"); + Directory.CreateDirectory(shimDir); + string shim = Path.Combine(shimDir, "dsh.cmd"); + File.WriteAllText(shim, "@echo off\r\n"); + + string originalPath = Environment.GetEnvironmentVariable("PATH"); + string originalCli = Environment.GetEnvironmentVariable("DSH_CLI"); + try + { + Environment.SetEnvironmentVariable("DSH_CLI", null); + Environment.SetEnvironmentVariable("PATH", shimDir); + + Assert.IsTrue(DeepSeekHarnessConfigurator.TryResolveDshExecutable(out string path, out bool isCmdShim)); + Assert.AreEqual(shim, path); + Assert.IsTrue(isCmdShim, ".cmd must be reported as a cmd shim"); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + Environment.SetEnvironmentVariable("DSH_CLI", originalCli); + } + } + + [Test] + public void RunDshCommand_RoutesCmdShimThroughCmdExe() + { + if (Path.DirectorySeparatorChar != '\\') Assert.Ignore("cmd shim routing is Windows-specific"); + + string shim = Path.Combine(_tempDir, "dsh.cmd"); + File.WriteAllText(shim, "@echo off\r\necho shim-ok %*\r\nexit /b 0\r\n"); + + string originalCli = Environment.GetEnvironmentVariable("DSH_CLI"); + try + { + Environment.SetEnvironmentVariable("DSH_CLI", shim); + var (exitCode, output) = DeepSeekHarnessConfigurator.RunDshCommand( + new[] { "plugin", "--profile", "desktop", "add", "C:\\bundle dir" }); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains("shim-ok", output); + StringAssert.Contains("plugin --profile desktop add", output); + StringAssert.Contains("C:\\bundle dir", output); + } + finally + { + Environment.SetEnvironmentVariable("DSH_CLI", originalCli); + } + } + // ---------------------------------------------------------------- helpers private static string BuildHttpBlock(string url) @@ -455,6 +535,14 @@ private static void WriteBundleFiles(string dshHome, string patchBlock) DshBundleWriter.WriteBundle(dshHome, patchBlock); } + /// Creates a bare profile directory with a package.json (no bundle entry). + private static void WriteProfilePackage(string dshHome, string profile) + { + string profileDir = Path.Combine(dshHome, "profiles", profile); + Directory.CreateDirectory(profileDir); + File.WriteAllText(Path.Combine(profileDir, "package.json"), "{\n \"name\": \"test-profile\"\n}\n"); + } + private static void WriteProfileBundleEntry(string dshHome, string profile) { string profileDir = Path.Combine(dshHome, "profiles", profile); diff --git a/website/docs/getting-started/clients.md b/website/docs/getting-started/clients.md index 1f3da153ca..d2ef454de9 100644 --- a/website/docs/getting-started/clients.md +++ b/website/docs/getting-started/clients.md @@ -27,7 +27,7 @@ MCP for Unity auto-configures every client the package detects on your machine. | **OpenClaw** | HTTP / stdio | yes | yes | yes | Requires `openclaw-mcp-bridge` plugin enabled. Follows MCP for Unity's transport choice. | | **Antigravity** | HTTP | yes | yes | varies | Requires an MCP toggle in Antigravity settings. | | **Pi** | stdio / HTTP | yes | yes | yes | Needs an MCP extension (Pi ships no MCP client of its own): `pi install npm:pi-mcp-adapter`. Writes the shared `~/.config/mcp/mcp.json`. | -| **DeepSeek Harness** | HTTP / stdio | yes | yes | yes | Writes a managed block into `$DSH_HOME/cordis.patch.yml` (applies to every profile). Tools appear as `mcp__unityMCP__`. | +| **DeepSeek Harness** | HTTP / stdio | yes | yes | yes | Installs a configuration-only DSH bundle (`$DSH_HOME/bundles/unity-mcp/`) into a DSH profile via `dsh plugin add`. Tools appear as `mcp__unityMCP__`. | ## How to pick diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index 014546d467..32534e1749 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -173,11 +173,12 @@ Some clients cannot be handled by the generic JSON configurator alone. - The bridge exposes a single proxy tool such as `unityMCP__call`, which then forwards to Unity MCP tool names. - OpenClaw support follows the currently selected MCP for Unity transport (via `openclaw-mcp-bridge`). -### DeepSeek Harness (Cordis patch YAML) +### DeepSeek Harness (native DSH bundle) -- Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, configured as Cordis rows in a **YAML patch layer** — not a JSON/TOML config. -- Target file is `$DSH_HOME/cordis.patch.yml` (the `DSH_HOME` environment variable wins, default `~/.dsh`). The home patch layer applies to every DSH profile (web, sdk, headless, acp); per-profile `cordis.patch.yml` files are documented in the manual snippet instead. -- The row shape (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): +- Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, configured as Cordis rows — not a JSON/TOML config file. +- Configure writes a **configuration-only DSH bundle** to `$DSH_HOME/bundles/unity-mcp/` (the `DSH_HOME` environment variable wins, default `~/.dsh`) containing a `package.json` manifest (`dsh.bundle.patch` → `./cordis.patch.yml`) and the patch itself, then installs it into a DSH profile with `dsh plugin --profile add `. The profile is `desktop` by default; set `DSH_PROFILE` to target another profile. +- Installing as a bundle (instead of editing `$DSH_HOME/cordis.patch.yml`) keeps the row **profile-scoped and removable** through DSH's own plugin manager (`dsh plugin --profile remove @local/unity-mcp-dsh`). The root home patch applies to *every* profile and collides with rows from other sources, so it is only used for one-shot experiments (`dsh web --patch `) and migration cleanup. +- The row shape inside the bundle's `cordis.patch.yml` (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): ```yaml - insert: - id: unitymcp @@ -187,9 +188,10 @@ Some clients cannot be handled by the generic JSON configurator alone. transport: streamable-http # or stdio with command/args url: http://127.0.0.1:/mcp ``` -- All patch-file surgery lives in `DshConfigHelper` (`MCPForUnity/Editor/Helpers/DshConfigHelper.cs`), which manages **one marker-fenced block**. This is deliberate: the package has no YAML parser, and user patch files may contain YAML features this helper does not parse (e.g. `!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. Upsert replaces only the fenced region; status checks parse only the fenced region's known shape; Unregister removes only the fenced region. +- Bundle contents are wholly managed (files are overwritten in place). `DshConfigHelper` (`MCPForUnity/Editor/Helpers/DshConfigHelper.cs`) still owns the block shape and any marker-fenced surgery on *foreign* patch files: the package has no YAML parser, and user patch files may contain YAML features this helper does not parse (e.g. `!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. Configure/Unregister also migrate away a legacy managed block left in `$DSH_HOME/cordis.patch.yml` by earlier versions, so exactly one row exists. - After a DSH reload/restart, Unity tools surface as `mcp__unityMCP__` (DSH's server-qualified MCP naming). -- Detection is fail-closed: DSH counts as installed only once it has run at least once (its home directory exists). +- Detection is fail-closed: DSH counts as installed only once it has run at least once (its home directory exists). Configure refuses to run when the target profile does not exist, because `dsh plugin add` silently initializes a missing profile as a bare, unusable one. +- Status checks never rewrite anything: bundle install is a user action only, so opening a project never modifies DSH configuration. --- From bf2fde1a1884f543b38c2b39d9131d1f870c8439 Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 15:56:44 +0200 Subject: [PATCH 05/11] fix(clients): emit failOnStartupError in the unityMCP row Matches DSH's own bundle template and the behavior proven in the live end-to-end run: mount failures surface as visible entry activation errors instead of silently degrading. --- MCPForUnity/Editor/Helpers/DshConfigHelper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs index 745cfa37b2..0dc7808320 100644 --- a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs @@ -81,6 +81,7 @@ public static string BuildUnityMcpBlock( } } + lines.Add(" failOnStartupError: true"); lines.Add(EndMarker); return string.Join("\n", lines) + "\n"; } From 9fca4a235b10ee7cb08c2d351454581488e3502e Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 18:58:59 +0200 Subject: [PATCH 06/11] fix(clients): compare uvx package sources as text, not file paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PathsEqual runs Path.GetFullPath, which throws on the characters package specs contain ('>'), and its catch returns false for identical strings — so the stdio status check could never match a PyPI spec and always reported IncorrectPath (other clients mask this with auto-rewrite). Add PackageSourcesEqual: specs compare as text, path-like sources as normalized paths. Adds the stdio status tests the suite was missing (all previous status tests used HTTP). --- .../DeepSeekHarnessConfigurator.cs | 2 +- .../Editor/Helpers/McpConfigurationHelper.cs | 26 +++++++ .../EditMode/Clients/DshConfiguratorTests.cs | 69 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index 49173f1fdd..de69991ecb 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -188,7 +188,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) string expected = GetExpectedPackageSourceForValidation(); string configured = McpConfigurationHelper.ExtractUvxUrl(args); if (!string.IsNullOrEmpty(configured) && !string.IsNullOrEmpty(expected) - && McpConfigurationHelper.PathsEqual(configured, expected)) + && McpConfigurationHelper.PackageSourcesEqual(configured, expected)) { client.SetStatus(McpStatus.Configured); } diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 61eccb13be..9367bb5bca 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -209,6 +209,32 @@ public static string ExtractUvxUrl(string[] args) return null; } + /// + /// Compares uvx package sources. Spec strings (e.g. mcpforunityserver>=0.0.0a0) + /// compare as text; path-like sources (local file: overrides, directories) + /// compare as normalized paths. must not be used for specs: + /// Path.GetFullPath throws on characters specs contain (>, =) + /// and its catch makes identical strings compare unequal. + /// + public static bool PackageSourcesEqual(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false; + a = a.Trim(); + b = b.Trim(); + + if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) return true; + + if (LooksLikeFilePath(a) && LooksLikeFilePath(b)) return PathsEqual(a, b); + return false; + } + + private static bool LooksLikeFilePath(string source) + { + return source.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || source.IndexOfAny(new[] { '/', '\\' }) >= 0 + || (source.Length > 1 && source[1] == ':'); + } + public static bool PathsEqual(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index e71cf5706b..4686a67790 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -342,6 +342,66 @@ public void CheckStatus_HttpMismatch_ReturnsIncorrectPath_WithoutRewrite() }); } + [Test] + public void CheckStatus_StdioMatch_Configured() + { + WithHttpTransport(false, () => + { + // The --from spec that Configure would really write, so the test matches + // in any environment (prerelease range, beta pin, or local override). + string expected = AssetPathUtility.GetMcpServerPackageSource(); + WriteProfileBundleEntry(_tempDir, "desktop"); + WriteBundleFiles(_tempDir, BuildStdioBlock(expected)); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + var status = configurator.CheckStatus(attemptAutoRewrite: false); + + Assert.AreEqual(McpStatus.Configured, status, + "identical --from spec must validate (specs are text, not file paths)"); + }); + } + + [Test] + public void CheckStatus_StdioSpecMismatch_NotConfigured() + { + WithHttpTransport(false, () => + { + string expected = AssetPathUtility.GetMcpServerPackageSource(); + WriteProfileBundleEntry(_tempDir, "desktop"); + WriteBundleFiles(_tempDir, BuildStdioBlock(expected + ".x")); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + var status = configurator.CheckStatus(attemptAutoRewrite: false); + + Assert.AreNotEqual(McpStatus.Configured, status, + "a different --from spec must not read as Configured"); + }); + } + + [Test] + public void PackageSourcesEqual_SpecsCompareAsText() + { + // Regression: PathsEqual compared specs as file paths, and Path.GetFullPath + // throws on '>' — so identical specs compared unequal and status was always + // IncorrectPath (masked only by auto-rewrite). + Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual( + "mcpforunityserver>=0.0.0a0", "mcpforunityserver>=0.0.0a0")); + Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual( + "mcpforunityserver>=0.0.0a0", " MCPforUnityServer>=0.0.0a0 ")); + Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual( + "mcpforunityserver>=0.0.0a0", "mcpforunityserver>=0.0.1a0")); + Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual(null, "x")); + Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual("x", " ")); + } + + [Test] + public void PackageSourcesEqual_PathsCompareNormalized() + { + Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual("file:C:\\dev\\Server", "file:C:\\dev\\Server")); + Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual("C:\\dev\\Server", "c:/dev/Server")); + Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual("C:\\dev\\Server", "C:\\dev\\Other")); + } + [Test] public void Configure_WritesBundleAndInvokesSeam() { @@ -530,6 +590,15 @@ private static string BuildHttpBlock(string url) apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); } + private static string BuildStdioBlock(string fromSpec) + { + string[] uvxArgs = { "--from", fromSpec, "mcp-for-unity", "--transport", "stdio" }; + return DshConfigHelper.BuildUnityMcpBlock( + useHttp: false, httpUrl: null, + uvxCommand: "uvx.exe", uvxArgs: uvxArgs, + apiKeyHeader: null, apiKey: null, windowsSystemRoot: "C:\\WINDOWS"); + } + private static void WriteBundleFiles(string dshHome, string patchBlock) { DshBundleWriter.WriteBundle(dshHome, patchBlock); From 163edd29a833232cbfe3e765b101d369b9b4ab63 Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 20:24:08 +0200 Subject: [PATCH 07/11] =?UTF-8?q?fix(clients):=20address=20CodeRabbit=20re?= =?UTF-8?q?view=20=E2=80=94=20process=20deadlock,=20per-profile=20bundles,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunDshCommand drains stdout/stderr concurrently (ReadToEndAsync started before the timed wait). Sequential blocking reads could deadlock when the child filled the other pipe, and a hung dsh would freeze the editor because the timeout sat after the reads. - Bundle paths are profile-qualified (\/bundles//unity-mcp/): dsh plugin add symlinks the bundle directory into the profile, so a shared path let one profile's Unregister dangle another's link. Profile names are validated against path traversal. Regression tests cover cross-profile isolation. - Docs: Auto-config cell is 'no (manual Configure)' per SupportsAutoConfigure => false; bundle paths updated. - Docstrings: one-line summaries for the 44 diff functions that lacked them. --- .../DeepSeekHarnessConfigurator.cs | 39 ++++++--- MCPForUnity/Editor/Helpers/DshBundleWriter.cs | 33 ++++++-- .../Editor/Helpers/McpConfigurationHelper.cs | 1 + .../EditMode/Clients/DshBundleWriterTests.cs | 62 +++++++++++--- .../EditMode/Clients/DshConfiguratorTests.cs | 84 +++++++++++++++++-- website/docs/getting-started/clients.md | 2 +- website/docs/guides/client-configurators.md | 2 +- 7 files changed, 183 insertions(+), 40 deletions(-) diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index de69991ecb..892abb42f8 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -38,9 +38,9 @@ internal DeepSeekHarnessConfigurator(string dshHomeOverride, string profileOverr : base(new McpClient { name = "DeepSeek Harness", - windowsConfigPath = BuildConfigPath(dshHomeOverride), - macConfigPath = BuildConfigPath(dshHomeOverride), - linuxConfigPath = BuildConfigPath(dshHomeOverride) + windowsConfigPath = BuildConfigPath(dshHomeOverride, profileOverride), + macConfigPath = BuildConfigPath(dshHomeOverride, profileOverride), + linuxConfigPath = BuildConfigPath(dshHomeOverride, profileOverride) }) { _dshHomeOverride = dshHomeOverride; @@ -65,13 +65,17 @@ internal DeepSeekHarnessConfigurator(string dshHomeOverride, string profileOverr // ------------------------------------------------------------------ paths - private static string BuildConfigPath(string dshHomeOverride) + /// Path to the managed bundle patch for the resolved home and profile. + private static string BuildConfigPath(string dshHomeOverride, string profileOverride) { return Path.Combine( - DshBundleWriter.GetBundleDir(ResolveDshHome(dshHomeOverride)), + DshBundleWriter.GetBundleDir( + ResolveDshHome(dshHomeOverride), + ResolveDshProfile(profileOverride)), "cordis.patch.yml"); } + /// Config path shown in the Setup window (the managed bundle patch). public override string GetConfigPath() => CurrentOsPath(); /// @@ -106,6 +110,7 @@ internal static string ResolveDshProfile(string profileOverride) // ------------------------------------------------------------------ status + /// Configure when unconfigured, Unregister when configured. public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Configure"; @@ -125,7 +130,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) // Bundle files must exist. string bundlePatchPath = Path.Combine( - DshBundleWriter.GetBundleDir(dshHome), "cordis.patch.yml"); + DshBundleWriter.GetBundleDir(dshHome, profile), "cordis.patch.yml"); if (!File.Exists(bundlePatchPath)) { client.SetStatus(McpStatus.NotConfigured); @@ -228,7 +233,7 @@ public override void Configure() string profile = ResolveDshProfile(_profileOverride); EnsureProfileExists(dshHome, profile); string block = BuildBlock(); - string bundleDir = DshBundleWriter.WriteBundle(dshHome, block); + string bundleDir = DshBundleWriter.WriteBundle(dshHome, profile, block); // Install into the named DSH profile through the plugin manager. string manualCommand = $"dsh plugin --profile {profile} add {bundleDir}"; @@ -265,7 +270,7 @@ public override void Unregister() } catch { } - DshBundleWriter.TryDeleteBundle(dshHome); + DshBundleWriter.TryDeleteBundle(dshHome, profile); RemoveLegacyRootPatchBlock(dshHome); client.SetStatus(McpStatus.NotConfigured); @@ -274,13 +279,14 @@ public override void Unregister() // ------------------------------------------------------------------ manual snippet / steps + /// Renders the bundle files and the CLI install/remove commands. public override string GetManualSnippet() { try { string dshHome = ResolveDshHome(_dshHomeOverride); string profile = ResolveDshProfile(_profileOverride); - string bundleDir = DshBundleWriter.GetBundleDir(dshHome); + string bundleDir = DshBundleWriter.GetBundleDir(dshHome, profile); var sb = new StringBuilder(); sb.AppendLine("# Mount the Unity MCP server in DeepSeek Harness as a native DSH bundle."); @@ -310,6 +316,7 @@ public override string GetManualSnippet() } } + /// Short user-facing steps shown under the manual snippet. public override IList GetInstallationSteps() => new List { "Install DeepSeek Harness and run any 'dsh' command once (creates ~/.dsh)", @@ -320,6 +327,7 @@ public override string GetManualSnippet() // ------------------------------------------------------------------ internals + /// Builds the managed block from the current transport preference and package settings. private string BuildBlock() { var transport = HttpEndpointUtility.GetCurrentServerTransport(); @@ -485,13 +493,18 @@ internal static (int ExitCode, string Output) RunDshCommand(string[] args) { if (process == null) return (-1, "Failed to start dsh process"); - string output = process.StandardOutput.ReadToEnd() - + process.StandardError.ReadToEnd(); + // Read both pipes concurrently and only then wait. Sequential ReadToEnd + // deadlocks when the child fills the other pipe's buffer first, and a + // hung child would block the read forever, past the timeout below. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); if (!process.WaitForExit(60_000)) { try { process.Kill(); } catch { } - return (-1, output + "\n(dsh timed out after 60s)"); + return (-1, "(dsh timed out after 60s)"); } + process.WaitForExit(); + string output = stdoutTask.Result + stderrTask.Result; return (process.ExitCode, output); } } @@ -543,12 +556,14 @@ internal static bool TryResolveDshExecutable(out string path, out bool isCmdShim return false; } + /// True for .cmd/.bat launchers that must run through cmd.exe. private static bool IsCmdShim(string path) { return path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase); } + /// Wraps an argument in double quotes for cmd.exe command lines. private static string QuoteArg(string arg) { return "\"" + (arg ?? string.Empty) + "\""; diff --git a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs index d16ed317af..488ab4bc91 100644 --- a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs +++ b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs @@ -42,18 +42,22 @@ public static class DshBundleWriter /// Writes (or overwrites) the bundle directory containing the manifest and the /// given patch block. The bundle directory is entirely managed by us, so no marker /// surgery is needed inside it — files are overwritten wholesale. Returns the - /// bundle directory path. + /// bundle directory path. The path is profile-qualified because + /// dsh plugin add symlinks this directory into the profile: sharing one + /// directory across profiles would let one profile's Unregister dangle another's + /// link. /// /// DSH home directory ($DSH_HOME or ~/.dsh). + /// DSH profile the bundle is installed into. /// Full YAML block from . - public static string WriteBundle(string dshHome, string patchBlock) + public static string WriteBundle(string dshHome, string profile, string patchBlock) { if (string.IsNullOrEmpty(dshHome)) throw new ArgumentException("dshHome is required", nameof(dshHome)); if (patchBlock == null) throw new ArgumentNullException(nameof(patchBlock)); - string dir = GetBundleDir(dshHome); + string dir = GetBundleDir(dshHome, profile); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, ManifestFileName), ManifestJson); @@ -64,21 +68,32 @@ public static string WriteBundle(string dshHome, string patchBlock) /// /// Removes the bundle directory recursively. Returns true when the directory - /// existed and was deleted; false when it was already absent. + /// existed and was deleted; false when it was already absent. Only the calling + /// profile's bundle is removed — other profiles keep their own directories. /// - public static bool TryDeleteBundle(string dshHome) + public static bool TryDeleteBundle(string dshHome, string profile) { if (string.IsNullOrEmpty(dshHome)) return false; - string dir = GetBundleDir(dshHome); + string dir = GetBundleDir(dshHome, profile); if (!Directory.Exists(dir)) return false; Directory.Delete(dir, true); return true; } - /// Path to the bundle directory that holds the manifest and patch. - public static string GetBundleDir(string dshHome) + /// + /// Path to the profile-qualified bundle directory that holds the manifest and patch. + /// + public static string GetBundleDir(string dshHome, string profile) { - return Path.Combine(dshHome, "bundles", BundleDirName); + if (string.IsNullOrWhiteSpace(profile)) + throw new ArgumentException("profile is required", nameof(profile)); + if (profile == "." || profile == ".." + || profile.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || profile.IndexOfAny(new[] { '/', '\\' }) >= 0) + { + throw new ArgumentException($"profile is not a valid directory name: '{profile}'", nameof(profile)); + } + return Path.Combine(dshHome, "bundles", profile, BundleDirName); } } } diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 9367bb5bca..4a03ea11f8 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -228,6 +228,7 @@ public static bool PackageSourcesEqual(string a, string b) return false; } + /// True for file: urls, drive-letter paths, or strings containing path separators. private static bool LooksLikeFilePath(string source) { return source.StartsWith("file:", StringComparison.OrdinalIgnoreCase) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs index b2c5acd1d0..b2ac24f214 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs @@ -14,6 +14,7 @@ public class DshBundleWriterTests { private string _tempDir; + /// Creates a fresh temp home directory per test. [SetUp] public void SetUp() { @@ -21,12 +22,14 @@ public void SetUp() Directory.CreateDirectory(_tempDir); } + /// Removes the temp home directory. [TearDown] public void TearDown() { try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } } + /// WriteBundle creates both manifest and patch at the profile-qualified path. [Test] public void WriteBundle_CreatesBothFiles() { @@ -35,17 +38,18 @@ public void WriteBundle_CreatesBothFiles() uvxCommand: null, uvxArgs: null, apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); - string dir = DshBundleWriter.WriteBundle(_tempDir, block); + string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", block); Assert.IsTrue(File.Exists(Path.Combine(dir, "package.json"))); Assert.IsTrue(File.Exists(Path.Combine(dir, "cordis.patch.yml"))); - Assert.AreEqual(Path.Combine(_tempDir, "bundles", "unity-mcp"), dir); + Assert.AreEqual(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"), dir); } + /// The manifest matches the exact content DSH's plugin manager expects. [Test] public void WriteBundle_ExactManifestContent() { - string dir = DshBundleWriter.WriteBundle(_tempDir, "# test"); + string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", "# test"); string manifest = File.ReadAllText(Path.Combine(dir, "package.json")); StringAssert.Contains("\"name\": \"@local/unity-mcp-dsh\"", manifest); @@ -55,6 +59,7 @@ public void WriteBundle_ExactManifestContent() StringAssert.Contains("\"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }", manifest); } + /// The patch file receives the block byte-for-byte. [Test] public void WriteBundle_PatchContentMatchesInput() { @@ -63,40 +68,77 @@ public void WriteBundle_PatchContentMatchesInput() uvxCommand: null, uvxArgs: null, apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); - string dir = DshBundleWriter.WriteBundle(_tempDir, block); + string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", block); string written = File.ReadAllText(Path.Combine(dir, "cordis.patch.yml")); Assert.AreEqual(block, written, "patch file must contain the block verbatim"); } + /// Reconfiguring overwrites the previous bundle contents. [Test] public void WriteBundle_OverwritesExistingFiles() { - DshBundleWriter.WriteBundle(_tempDir, "# first"); - string dir = DshBundleWriter.WriteBundle(_tempDir, "# second"); + DshBundleWriter.WriteBundle(_tempDir, "desktop", "# first"); + string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", "# second"); string written = File.ReadAllText(Path.Combine(dir, "cordis.patch.yml")); Assert.AreEqual("# second", written, "second write must overwrite the first"); } + /// TryDeleteBundle removes the directory and reports having done so. [Test] public void TryDeleteBundle_RemovesDir_ReturnsTrue() { - DshBundleWriter.WriteBundle(_tempDir, "# test"); - string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + DshBundleWriter.WriteBundle(_tempDir, "desktop", "# test"); + string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); Assert.IsTrue(Directory.Exists(bundleDir)); - bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir); + bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir, "desktop"); Assert.IsTrue(deleted); Assert.IsFalse(Directory.Exists(bundleDir)); } + /// TryDeleteBundle reports false when the bundle is already absent. [Test] public void TryDeleteBundle_AbsentDir_ReturnsFalse() { - bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir); + bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir, "desktop"); Assert.IsFalse(deleted); } + + /// + /// Regression: bundle paths are profile-qualified because dsh plugin add symlinks + /// the directory — a shared path would let one profile's delete dangle another's link. + /// + [Test] + public void Bundles_AreIsolatedPerProfile() + { + DshBundleWriter.WriteBundle(_tempDir, "desktop", "# desktop-block"); + DshBundleWriter.WriteBundle(_tempDir, "web", "# web-block"); + + string desktopDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); + string webDir = Path.Combine(_tempDir, "bundles", "web", "unity-mcp"); + Assert.AreNotEqual(desktopDir, webDir); + + Assert.IsTrue(DshBundleWriter.TryDeleteBundle(_tempDir, "desktop")); + + Assert.IsFalse(Directory.Exists(desktopDir), "target profile bundle must be deleted"); + Assert.IsTrue(Directory.Exists(webDir), "other profile bundle must survive"); + Assert.AreEqual("# web-block", + File.ReadAllText(Path.Combine(webDir, "cordis.patch.yml")), + "other profile bundle content must be intact"); + } + + /// GetBundleDir rejects profile names that could escape the bundles directory. + [Test] + public void GetBundleDir_RejectsUnsafeProfileNames() + { + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, null)); + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, " ")); + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "..")); + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a/b")); + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a\\b")); + } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index 4686a67790..459cf88126 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -20,6 +20,7 @@ public class DshConfiguratorTests private string _tempDir; private Func _originalRunner; + /// Creates a fresh temp DSH home and saves the default command runner. [SetUp] public void SetUp() { @@ -28,6 +29,7 @@ public void SetUp() _originalRunner = DeepSeekHarnessConfigurator.DshCommandRunner; } + /// Restores the command runner and removes the temp DSH home. [TearDown] public void TearDown() { @@ -37,6 +39,7 @@ public void TearDown() // ---------------------------------------------------------------- helper: build + /// HTTP block emits the expected insert row and url. [Test] public void BuildBlock_Http_EmitsExpectedRows() { @@ -56,6 +59,7 @@ public void BuildBlock_Http_EmitsExpectedRows() StringAssert.DoesNotContain("command:", block); } + /// HTTP block emits an auth header row when an API key is configured. [Test] public void BuildBlock_Http_WithApiKey_EmitsHeader() { @@ -68,6 +72,7 @@ public void BuildBlock_Http_WithApiKey_EmitsHeader() StringAssert.Contains("X-API-Key: 's3cret'", block); } + /// stdio block emits command, args, and the Windows SystemRoot env row. [Test] public void BuildBlock_Stdio_EmitsCommandArgsAndWindowsEnv() { @@ -88,6 +93,7 @@ public void BuildBlock_Stdio_EmitsCommandArgsAndWindowsEnv() // ---------------------------------------------------------------- helper: parse + /// TryParseManagedBlock round-trips the HTTP block shape. [Test] public void TryParse_RoundTripsHttpBlock() { @@ -109,6 +115,7 @@ public void TryParse_RoundTripsHttpBlock() Assert.AreEqual("it's-a-key", headerValue, "doubled single quotes must unescape"); } + /// TryParseManagedBlock round-trips stdio rows containing Windows paths. [Test] public void TryParse_RoundTripsStdioBlock_WithWindowsPaths() { @@ -132,6 +139,7 @@ public void TryParse_RoundTripsStdioBlock_WithWindowsPaths() Assert.IsNull(headerValue, "env rows must not be mistaken for headers"); } + /// TryParseManagedBlock rejects an empty block. [Test] public void TryParse_RejectsEmptyBlock() { @@ -142,6 +150,7 @@ public void TryParse_RejectsEmptyBlock() // ---------------------------------------------------------------- helper: upsert/remove + /// Upsert into an empty file produces just the managed block. [Test] public void Upsert_IntoEmptyFile_ProducesValidListDocument() { @@ -152,6 +161,7 @@ public void Upsert_IntoEmptyFile_ProducesValidListDocument() StringAssert.EndsWith("\n", result); } + /// Upserting the same block twice is idempotent. [Test] public void Upsert_Twice_IsIdempotent() { @@ -165,6 +175,7 @@ public void Upsert_Twice_IsIdempotent() StringAssert.DoesNotContain("8090", twice); } + /// Upsert preserves unrelated user patch content byte-for-byte. [Test] public void Upsert_PreservesForeignUserPatches_ByteForByte() { @@ -187,6 +198,7 @@ public void Upsert_PreservesForeignUserPatches_ByteForByte() Assert.AreEqual(1, CountOccurrences(result, DshConfigHelper.BeginMarker)); } + /// Upsert appends cleanly when the file lacks a trailing newline. [Test] public void Upsert_AppendsWithoutTrailingNewlineInput() { @@ -196,6 +208,7 @@ public void Upsert_AppendsWithoutTrailingNewlineInput() StringAssert.StartsWith(existing + "\n", result); } + /// Remove deletes only the managed block. [Test] public void Remove_DeletesOnlyManagedBlock() { @@ -207,6 +220,7 @@ public void Remove_DeletesOnlyManagedBlock() Assert.AreEqual(userPatches, result, "only the managed block may be removed"); } + /// Remove leaves text unchanged when no block exists. [Test] public void Remove_WithoutBlock_ReturnsTextUnchanged() { @@ -214,6 +228,7 @@ public void Remove_WithoutBlock_ReturnsTextUnchanged() Assert.AreEqual(text, DshConfigHelper.RemoveManagedBlock(text)); } + /// Extract returns null without markers and the rows when markers exist. [Test] public void Extract_ReturnsNullWithoutMarkers_AndRowsWithMarkers() { @@ -227,14 +242,16 @@ public void Extract_ReturnsNullWithoutMarkers_AndRowsWithMarkers() // ---------------------------------------------------------------- configurator wiring + /// Config path points at the bundle patch inside the overridden DSH home. [Test] public void ConfigPath_UsesBundlePatchInsideOverrideHome() { var configurator = new DeepSeekHarnessConfigurator(_tempDir); - string expected = Path.Combine(_tempDir, "bundles", "unity-mcp", "cordis.patch.yml"); + string expected = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp", "cordis.patch.yml"); Assert.AreEqual(expected, configurator.GetConfigPath()); } + /// ResolveDshHome precedence: explicit override, then DSH_HOME, then ~/.dsh. [Test] public void ResolveDshHome_PrefersOverride_ThenEnv_ThenDotDsh() { @@ -257,6 +274,7 @@ public void ResolveDshHome_PrefersOverride_ThenEnv_ThenDotDsh() } } + /// ResolveDshProfile precedence: explicit override, then DSH_PROFILE, then desktop. [Test] public void ResolveDshProfile_PrefersOverride_ThenEnv_ThenDesktop() { @@ -277,6 +295,7 @@ public void ResolveDshProfile_PrefersOverride_ThenEnv_ThenDesktop() } } + /// IsInstalled follows existence of the DSH home directory. [Test] public void IsInstalled_FollowsParentDirectory() { @@ -285,6 +304,7 @@ public void IsInstalled_FollowsParentDirectory() Assert.IsTrue(new DeepSeekHarnessConfigurator(_tempDir).IsInstalled); } + /// A missing bundle reports NotConfigured and performs no writes or CLI calls. [Test] public void CheckStatus_MissingBundle_NotConfigured_NoAutoWrite() { @@ -300,10 +320,11 @@ public void CheckStatus_MissingBundle_NotConfigured_NoAutoWrite() Assert.AreEqual(McpStatus.NotConfigured, status); Assert.IsFalse(seamCalled, "CheckStatus must never invoke the dsh command seam"); - string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); Assert.IsFalse(Directory.Exists(bundleDir), "CheckStatus must never write bundle files"); } + /// A bundle matching the current HTTP endpoint reports Configured. [Test] public void CheckStatus_HttpMatch_Configured() { @@ -321,12 +342,13 @@ public void CheckStatus_HttpMatch_Configured() }); } + /// A stale HTTP url reports IncorrectPath without rewriting anything. [Test] public void CheckStatus_HttpMismatch_ReturnsIncorrectPath_WithoutRewrite() { WithHttpTransport(true, () => { - string bundlePatch = Path.Combine(_tempDir, "bundles", "unity-mcp", "cordis.patch.yml"); + string bundlePatch = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp", "cordis.patch.yml"); WriteBundleFiles(_tempDir, BuildHttpBlock("http://127.0.0.1:1/mcp")); WriteProfileBundleEntry(_tempDir, "desktop"); @@ -402,6 +424,7 @@ public void PackageSourcesEqual_PathsCompareNormalized() Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual("C:\\dev\\Server", "C:\\dev\\Other")); } + /// Configure writes the bundle and invokes dsh plugin add through the seam. [Test] public void Configure_WritesBundleAndInvokesSeam() { @@ -418,7 +441,7 @@ public void Configure_WritesBundleAndInvokesSeam() var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); - string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); Assert.IsTrue(File.Exists(Path.Combine(bundleDir, "package.json")), "manifest must exist"); Assert.IsTrue(File.Exists(Path.Combine(bundleDir, "cordis.patch.yml")), "patch must exist"); @@ -431,6 +454,7 @@ public void Configure_WritesBundleAndInvokesSeam() }); } + /// Configure removes a legacy managed block from the root home patch. [Test] public void Configure_MigrationRemovesLegacyRootPatchBlock() { @@ -452,6 +476,7 @@ public void Configure_MigrationRemovesLegacyRootPatchBlock() }); } + /// Configure and Unregister leave user patch rows untouched. [Test] public void Configure_ThenUnregister_LeavesUserPatchesIntact() { @@ -474,10 +499,46 @@ public void Configure_ThenUnregister_LeavesUserPatchesIntact() Assert.AreEqual(McpStatus.NotConfigured, configurator.Status); Assert.AreEqual(userRow, File.ReadAllText(rootPatch), "unregister must not touch user rows"); - Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "unity-mcp")), "bundle dir must be deleted"); + Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp")), "bundle dir must be deleted"); }); } + /// + /// Regression: unregistering one profile must not delete or dangle another + /// profile's bundle (dsh plugin add symlinks the bundle directory). + /// + [Test] + public void Unregister_OnlyRemovesTargetProfileBundle() + { + WithHttpTransport(true, () => + { + DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); + + WriteProfilePackage(_tempDir, "desktop"); + WriteProfilePackage(_tempDir, "web"); + + var desktop = new DeepSeekHarnessConfigurator(_tempDir, "desktop"); + desktop.Configure(); + var web = new DeepSeekHarnessConfigurator(_tempDir, "web"); + web.Configure(); + + // Simulate the registration effect of the real `dsh plugin add`, which the + // stubbed seam does not perform. + WriteProfileBundleEntry(_tempDir, "desktop"); + WriteProfileBundleEntry(_tempDir, "web"); + + desktop.Unregister(); + + Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp")), + "target profile bundle must be deleted"); + Assert.IsTrue(Directory.Exists(Path.Combine(_tempDir, "bundles", "web", "unity-mcp")), + "other profile bundle must survive"); + Assert.AreEqual(McpStatus.Configured, web.CheckStatus(attemptAutoRewrite: false), + "other profile must stay configured"); + }); + } + + /// Unregister calls dsh plugin remove and deletes the bundle directory. [Test] public void Unregister_CallsRemoveAndDeletesBundle() { @@ -493,7 +554,7 @@ public void Unregister_CallsRemoveAndDeletesBundle() WriteProfilePackage(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); - string bundleDir = Path.Combine(_tempDir, "bundles", "unity-mcp"); + string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); Assert.IsTrue(Directory.Exists(bundleDir)); configurator.Unregister(); @@ -504,6 +565,7 @@ public void Unregister_CallsRemoveAndDeletesBundle() }); } + /// Configure fails fast on a missing profile without creating it or calling the CLI. [Test] public void Configure_MissingProfile_ThrowsWithoutCreatingProfile() { @@ -526,6 +588,7 @@ public void Configure_MissingProfile_ThrowsWithoutCreatingProfile() }); } + /// The resolver finds dsh.cmd on PATH and reports it as a cmd shim. [Test] public void TryResolveDshExecutable_FindsCmdShimOnPath() { @@ -554,6 +617,7 @@ public void TryResolveDshExecutable_FindsCmdShimOnPath() } } + /// RunDshCommand executes a .cmd shim through cmd.exe and forwards its arguments. [Test] public void RunDshCommand_RoutesCmdShimThroughCmdExe() { @@ -582,6 +646,7 @@ public void RunDshCommand_RoutesCmdShimThroughCmdExe() // ---------------------------------------------------------------- helpers + /// Builds an HTTP managed block for the given url. private static string BuildHttpBlock(string url) { return DshConfigHelper.BuildUnityMcpBlock( @@ -590,6 +655,7 @@ private static string BuildHttpBlock(string url) apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); } + /// Builds a stdio managed block with the given --from package spec. private static string BuildStdioBlock(string fromSpec) { string[] uvxArgs = { "--from", fromSpec, "mcp-for-unity", "--transport", "stdio" }; @@ -599,9 +665,10 @@ private static string BuildStdioBlock(string fromSpec) apiKeyHeader: null, apiKey: null, windowsSystemRoot: "C:\\WINDOWS"); } + /// Writes the bundle files for the default test profile. private static void WriteBundleFiles(string dshHome, string patchBlock) { - DshBundleWriter.WriteBundle(dshHome, patchBlock); + DshBundleWriter.WriteBundle(dshHome, "desktop", patchBlock); } /// Creates a bare profile directory with a package.json (no bundle entry). @@ -612,6 +679,7 @@ private static void WriteProfilePackage(string dshHome, string profile) File.WriteAllText(Path.Combine(profileDir, "package.json"), "{\n \"name\": \"test-profile\"\n}\n"); } + /// Creates a profile package.json that lists the bundle as installed. private static void WriteProfileBundleEntry(string dshHome, string profile) { string profileDir = Path.Combine(dshHome, "profiles", profile); @@ -620,6 +688,7 @@ private static void WriteProfileBundleEntry(string dshHome, string profile) File.WriteAllText(Path.Combine(profileDir, "package.json"), packageJson); } + /// Runs a body under the given transport preference, restoring the previous one afterwards. private static void WithHttpTransport(bool useHttp, Action body) { bool original = EditorConfigurationCache.Instance.UseHttpTransport; @@ -634,6 +703,7 @@ private static void WithHttpTransport(bool useHttp, Action body) } } + /// Counts non-overlapping occurrences of a substring. private static int CountOccurrences(string text, string needle) { int count = 0; diff --git a/website/docs/getting-started/clients.md b/website/docs/getting-started/clients.md index d2ef454de9..69cab79d25 100644 --- a/website/docs/getting-started/clients.md +++ b/website/docs/getting-started/clients.md @@ -27,7 +27,7 @@ MCP for Unity auto-configures every client the package detects on your machine. | **OpenClaw** | HTTP / stdio | yes | yes | yes | Requires `openclaw-mcp-bridge` plugin enabled. Follows MCP for Unity's transport choice. | | **Antigravity** | HTTP | yes | yes | varies | Requires an MCP toggle in Antigravity settings. | | **Pi** | stdio / HTTP | yes | yes | yes | Needs an MCP extension (Pi ships no MCP client of its own): `pi install npm:pi-mcp-adapter`. Writes the shared `~/.config/mcp/mcp.json`. | -| **DeepSeek Harness** | HTTP / stdio | yes | yes | yes | Installs a configuration-only DSH bundle (`$DSH_HOME/bundles/unity-mcp/`) into a DSH profile via `dsh plugin add`. Tools appear as `mcp__unityMCP__`. | +| **DeepSeek Harness** | HTTP / stdio | no (manual Configure) | yes | yes | Installs a configuration-only DSH bundle (`$DSH_HOME/bundles//unity-mcp/`) into a DSH profile via `dsh plugin add`. Tools appear as `mcp__unityMCP__`. | ## How to pick diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index 32534e1749..dd6dcedfcb 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -176,7 +176,7 @@ Some clients cannot be handled by the generic JSON configurator alone. ### DeepSeek Harness (native DSH bundle) - Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, configured as Cordis rows — not a JSON/TOML config file. -- Configure writes a **configuration-only DSH bundle** to `$DSH_HOME/bundles/unity-mcp/` (the `DSH_HOME` environment variable wins, default `~/.dsh`) containing a `package.json` manifest (`dsh.bundle.patch` → `./cordis.patch.yml`) and the patch itself, then installs it into a DSH profile with `dsh plugin --profile add `. The profile is `desktop` by default; set `DSH_PROFILE` to target another profile. +- Configure writes a **configuration-only DSH bundle** to `$DSH_HOME/bundles//unity-mcp/` (the `DSH_HOME` environment variable wins, default `~/.dsh`) containing a `package.json` manifest (`dsh.bundle.patch` → `./cordis.patch.yml`) and the patch itself, then installs it into a DSH profile with `dsh plugin --profile add `. The bundle path is profile-qualified because `dsh plugin add` symlinks the directory into the profile — one shared directory would let one profile's Unregister dangle another profile's link. The profile is `desktop` by default; set `DSH_PROFILE` to target another profile. - Installing as a bundle (instead of editing `$DSH_HOME/cordis.patch.yml`) keeps the row **profile-scoped and removable** through DSH's own plugin manager (`dsh plugin --profile remove @local/unity-mcp-dsh`). The root home patch applies to *every* profile and collides with rows from other sources, so it is only used for one-shot experiments (`dsh web --patch `) and migration cleanup. - The row shape inside the bundle's `cordis.patch.yml` (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): ```yaml From adfbbc83b966f3ca5229d50302e007ab3cf49c55 Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 21:01:09 +0200 Subject: [PATCH 08/11] fix(clients): keep bundle on failed removal, quote args against cmd.exe metacharacters - Unregister now checks the dsh plugin remove result: on failure it keeps the bundle (the profile still links the directory; deleting it would dangle the symlink) and reports the manual command. When the profile does not list the bundle, leftover files are deleted without invoking the CLI. - Every process argument is double-quoted with standard CommandLineToArgvW escaping: cmd.exe metacharacters (& | < > ^) are literal inside quotes, so names like R&D and home paths containing ampersands work. Percent cannot be escaped on a cmd /c command line (%% stays literal while %VAR% expands even inside quotes), so profile names reject percent and double-quote outright. --- .../DeepSeekHarnessConfigurator.cs | 60 ++++++++++++++----- MCPForUnity/Editor/Helpers/DshBundleWriter.cs | 8 ++- .../EditMode/Clients/DshBundleWriterTests.cs | 6 ++ .../EditMode/Clients/DshConfiguratorTests.cs | 60 ++++++++++++++++++- 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index 892abb42f8..26fe8c596e 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -260,15 +260,23 @@ public override void Unregister() string dshHome = ResolveDshHome(_dshHomeOverride); string profile = ResolveDshProfile(_profileOverride); - // Best-effort: remove from DSH plugin manager (ignore failure if dsh is missing). - try + if (ProfileListsBundle(dshHome, profile, DshBundleWriter.BundleName)) { - DshCommandRunner(new[] + string manualCommand = $"dsh plugin --profile {profile} remove {DshBundleWriter.BundleName}"; + (int exitCode, string output) = DshCommandRunner(new[] { "plugin", "--profile", profile, "remove", DshBundleWriter.BundleName }); + if (exitCode != 0) + { + // Keep the bundle: the profile still links this directory, and deleting + // the source now would leave a dangling symlink and break the bundle at + // the next reload. Report the failure so the user can retry. + throw new InvalidOperationException( + $"Failed to remove DSH bundle via '{manualCommand}' (exit code {exitCode}): {output}\n" + + $"Run this command manually: {manualCommand}"); + } } - catch { } DshBundleWriter.TryDeleteBundle(dshHome, profile); RemoveLegacyRootPatchBlock(dshHome); @@ -563,29 +571,51 @@ private static bool IsCmdShim(string path) || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase); } - /// Wraps an argument in double quotes for cmd.exe command lines. + /// Wraps an argument in double quotes, escaping embedded quotes and backslashes. private static string QuoteArg(string arg) { - return "\"" + (arg ?? string.Empty) + "\""; + // Standard CreateProcess/CommandLineToArgvW quoting: backslashes before a quote + // are doubled and embedded quotes escaped. cmd.exe metacharacters (& | < > ^) + // are literal inside double quotes, so quoting every argument also protects + // profile names like "R&D". '%' cannot be escaped this way (cmd expands %NAME% + // even inside quotes and leaves %% literal on the command line), so it is + // rejected in profile names instead. + string value = arg ?? string.Empty; + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + int backslashes = 0; + foreach (char c in value) + { + if (c == '\\') { backslashes++; continue; } + if (c == '"') + { + sb.Append('\\', backslashes * 2 + 1); + sb.Append('"'); + backslashes = 0; + continue; + } + sb.Append('\\', backslashes); + backslashes = 0; + sb.Append(c); + } + sb.Append('\\', backslashes * 2); + sb.Append('"'); + return sb.ToString(); } /// Builds a process argument string with Windows-compatible quoting. private static string BuildArgumentsString(string[] args) { if (args == null || args.Length == 0) return string.Empty; + + // Quote every argument, not only those with spaces: values may carry cmd.exe + // metacharacters (a profile named "R&D", a home path containing "&"), which + // are literal only inside double quotes. var sb = new StringBuilder(); for (int i = 0; i < args.Length; i++) { if (i > 0) sb.Append(' '); - string arg = args[i] ?? string.Empty; - if (arg.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0) - { - sb.Append('"').Append(arg.Replace("\"", "\\\"")).Append('"'); - } - else - { - sb.Append(arg); - } + sb.Append(QuoteArg(args[i])); } return sb.ToString(); } diff --git a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs index 488ab4bc91..637329424b 100644 --- a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs +++ b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs @@ -82,6 +82,10 @@ public static bool TryDeleteBundle(string dshHome, string profile) /// /// Path to the profile-qualified bundle directory that holds the manifest and patch. + /// Profile names are validated because the name flows into filesystem paths and into + /// the cmd.exe command line: '%' is rejected (cmd expands %NAME% patterns even + /// inside double quotes) and '"' breaks argument quoting. cmd.exe metacharacters such + /// as '&' are allowed because arguments are always double-quoted. /// public static string GetBundleDir(string dshHome, string profile) { @@ -89,9 +93,9 @@ public static string GetBundleDir(string dshHome, string profile) throw new ArgumentException("profile is required", nameof(profile)); if (profile == "." || profile == ".." || profile.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 - || profile.IndexOfAny(new[] { '/', '\\' }) >= 0) + || profile.IndexOfAny(new[] { '/', '\\', '%', '"' }) >= 0) { - throw new ArgumentException($"profile is not a valid directory name: '{profile}'", nameof(profile)); + throw new ArgumentException($"profile is not a safe directory name: '{profile}'", nameof(profile)); } return Path.Combine(dshHome, "bundles", profile, BundleDirName); } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs index b2ac24f214..4b5cae201c 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs @@ -139,6 +139,12 @@ public void GetBundleDir_RejectsUnsafeProfileNames() Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "..")); Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a/b")); Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a\\b")); + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a%b"), + "cmd.exe expands %NAME% patterns even inside quotes"); + Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a\"b"), + "double quotes break argument quoting"); + // cmd.exe metacharacters are safe: arguments are always double-quoted. + StringAssert.Contains("R&D", DshBundleWriter.GetBundleDir(_tempDir, "R&D")); } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index 459cf88126..a52c65e326 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -503,6 +503,56 @@ public void Configure_ThenUnregister_LeavesUserPatchesIntact() }); } + /// Regression: a failed dsh plugin remove must keep the bundle (no dangling link) and report the failure. + [Test] + public void Unregister_RemovalFailure_KeepsBundleAndThrows() + { + WithHttpTransport(true, () => + { + DeepSeekHarnessConfigurator.DshCommandRunner = args => + string.Join(" ", args).Contains("remove") ? (1, "remove failed") : (0, "ok"); + WriteProfilePackage(_tempDir, "desktop"); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + WriteProfileBundleEntry(_tempDir, "desktop"); + + Assert.Throws(() => configurator.Unregister()); + + Assert.IsTrue(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp")), + "bundle must be kept while the profile still links it"); + Assert.AreNotEqual(McpStatus.NotConfigured, configurator.Status, + "a failed removal must not report NotConfigured"); + }); + } + + /// Unregister deletes leftover bundle files without invoking the CLI when the profile does not list the bundle. + [Test] + public void Unregister_Unregistered_DeletesFilesWithoutRemove() + { + WithHttpTransport(true, () => + { + var calls = new List(); + DeepSeekHarnessConfigurator.DshCommandRunner = args => + { + calls.Add(string.Join(" ", args)); + return (0, "ok"); + }; + WriteProfilePackage(_tempDir, "desktop"); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + calls.Clear(); + + configurator.Unregister(); + + Assert.IsFalse(calls.Exists(c => c.Contains("remove")), + "CLI remove must not run when the profile does not list the bundle"); + Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"))); + Assert.AreEqual(McpStatus.NotConfigured, configurator.Status); + }); + } + /// /// Regression: unregistering one profile must not delete or dangle another /// profile's bundle (dsh plugin add symlinks the bundle directory). @@ -554,6 +604,10 @@ public void Unregister_CallsRemoveAndDeletesBundle() WriteProfilePackage(_tempDir, "desktop"); var configurator = new DeepSeekHarnessConfigurator(_tempDir); configurator.Configure(); + // Simulate the registration effect of the real `dsh plugin add`, which the + // stubbed seam does not perform; Unregister only runs `remove` when the + // profile lists the bundle. + WriteProfileBundleEntry(_tempDir, "desktop"); string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); Assert.IsTrue(Directory.Exists(bundleDir)); @@ -631,11 +685,13 @@ public void RunDshCommand_RoutesCmdShimThroughCmdExe() { Environment.SetEnvironmentVariable("DSH_CLI", shim); var (exitCode, output) = DeepSeekHarnessConfigurator.RunDshCommand( - new[] { "plugin", "--profile", "desktop", "add", "C:\\bundle dir" }); + new[] { "plugin", "--profile", "R&D", "add", "C:\\bundle dir" }); Assert.AreEqual(0, exitCode); StringAssert.Contains("shim-ok", output); - StringAssert.Contains("plugin --profile desktop add", output); + StringAssert.Contains("plugin", output); + // cmd.exe metacharacters must round-trip as data, not be interpreted. + StringAssert.Contains("R&D", output); StringAssert.Contains("C:\\bundle dir", output); } finally From 4503ddb288eeee99aef80b9b8e6452f62ad149a3 Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 12 Oct 2026 22:33:26 +0200 Subject: [PATCH 09/11] fix(clients): remove disabled-but-installed bundles through the CLI DSH's plugin-manager toggle keeps the dependency installed while dropping the entry from dsh.profile.bundles. Unregister skipped dsh plugin remove in that state and deleted the directory the dependency links. Unregister now runs the CLI when the profile declares the bundle in either place and only skips it for bare leftover files. Status checks keep the strict enabled look: a disabled bundle mounts no tools. Regression test: Unregister_DisabledButInstalled_StillRunsRemove. --- .../DeepSeekHarnessConfigurator.cs | 40 +++++++++++++++++- .../EditMode/Clients/DshConfiguratorTests.cs | 41 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index 26fe8c596e..ad34943069 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -260,7 +260,10 @@ public override void Unregister() string dshHome = ResolveDshHome(_dshHomeOverride); string profile = ResolveDshProfile(_profileOverride); - if (ProfileListsBundle(dshHome, profile, DshBundleWriter.BundleName)) + // Removal must consider a disabled-but-installed bundle: DSH's plugin-manager + // toggle drops the entry from dsh.profile.bundles while the dependency stays, + // so skipping `remove` for it would delete the directory the dependency links. + if (ProfileDeclaresBundle(dshHome, profile, DshBundleWriter.BundleName)) { string manualCommand = $"dsh plugin --profile {profile} remove {DshBundleWriter.BundleName}"; (int exitCode, string output) = DshCommandRunner(new[] @@ -439,6 +442,41 @@ private static void RemoveLegacyRootPatchBlock(string dshHome) McpConfigurationHelper.WriteAtomicFile(rootPatch, updated); } + /// + /// True when the profile declares the bundle anywhere: in dsh.profile.bundles + /// (enabled) or in dependencies (installed but possibly disabled through DSH's + /// plugin manager). Unregister uses this so a disabled-but-installed bundle still goes + /// through dsh plugin remove; status checks use + /// because a disabled bundle does not mount tools. + /// + private static bool ProfileDeclaresBundle(string dshHome, string profile, string bundleName) + { + string profilePackagePath = Path.Combine(dshHome, "profiles", profile, "package.json"); + if (!File.Exists(profilePackagePath)) return false; + + try + { + var json = JObject.Parse(File.ReadAllText(profilePackagePath)); + + var bundles = json["dsh"]?["profile"]?["bundles"] as JArray; + if (bundles != null) + { + foreach (var item in bundles) + { + if (string.Equals(item.ToString(), bundleName, StringComparison.Ordinal)) + return true; + } + } + + var dependencies = json["dependencies"] as JObject; + return dependencies != null && dependencies.Property(bundleName) != null; + } + catch + { + return false; + } + } + /// /// Checks whether the target profile's package.json lists the given bundle /// name in dsh.profile.bundles. diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs index a52c65e326..a99f552874 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs @@ -553,6 +553,38 @@ public void Unregister_Unregistered_DeletesFilesWithoutRemove() }); } + /// + /// Regression: a disabled-but-installed bundle (dependency present, absent from + /// dsh.profile.bundles after DSH's toggle) must still go through dsh plugin remove. + /// + [Test] + public void Unregister_DisabledButInstalled_StillRunsRemove() + { + WithHttpTransport(true, () => + { + var calls = new List(); + DeepSeekHarnessConfigurator.DshCommandRunner = args => + { + calls.Add(string.Join(" ", args)); + return (0, "ok"); + }; + WriteProfilePackage(_tempDir, "desktop"); + + var configurator = new DeepSeekHarnessConfigurator(_tempDir); + configurator.Configure(); + // Simulate the plugin-manager toggle OFF: the dependency stays installed + // while the entry disappears from dsh.profile.bundles. + WriteProfileDependencyOnly(_tempDir, "desktop"); + calls.Clear(); + + configurator.Unregister(); + + Assert.IsTrue(calls.Exists(c => c.Contains("remove") && c.Contains(DshBundleWriter.BundleName)), + "remove must run while the dependency is still declared"); + Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"))); + }); + } + /// /// Regression: unregistering one profile must not delete or dangle another /// profile's bundle (dsh plugin add symlinks the bundle directory). @@ -735,6 +767,15 @@ private static void WriteProfilePackage(string dshHome, string profile) File.WriteAllText(Path.Combine(profileDir, "package.json"), "{\n \"name\": \"test-profile\"\n}\n"); } + /// Creates a profile package.json declaring the bundle only as a dependency (disabled state). + private static void WriteProfileDependencyOnly(string dshHome, string profile) + { + string profileDir = Path.Combine(dshHome, "profiles", profile); + Directory.CreateDirectory(profileDir); + string packageJson = "{\n \"dependencies\": {\n \"" + DshBundleWriter.BundleName + "\": \"link:./somewhere\"\n }\n}\n"; + File.WriteAllText(Path.Combine(profileDir, "package.json"), packageJson); + } + /// Creates a profile package.json that lists the bundle as installed. private static void WriteProfileBundleEntry(string dshHome, string profile) { From 27f6aa1568f00bece7c44a000f446539b84e66bc Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 19 Oct 2026 11:35:58 +0200 Subject: [PATCH 10/11] refactor(clients): trim DeepSeek Harness to a manual configurator Per review direction, keep it in the same fashion as the other manual configurators and under roughly 200 lines, with the tests gone. The class now only renders the manual snippet (the two bundle files plus the quoted dsh plugin add and remove commands). DSH's plugin manager owns the install, enable/disable, and removal lifecycle from there. This drops the helper and test files from the earlier iterations on this branch, including the process execution and bundle management code. --- .../DeepSeekHarnessConfigurator.cs | 671 +++----------- MCPForUnity/Editor/Helpers/DshBundleWriter.cs | 103 --- .../Editor/Helpers/DshBundleWriter.cs.meta | 2 - MCPForUnity/Editor/Helpers/DshConfigHelper.cs | 318 ------- .../Editor/Helpers/DshConfigHelper.cs.meta | 11 - .../Editor/Helpers/McpConfigurationHelper.cs | 27 - .../EditMode/Clients/DshBundleWriterTests.cs | 150 ---- .../Clients/DshBundleWriterTests.cs.meta | 2 - .../EditMode/Clients/DshConfiguratorTests.cs | 816 ------------------ .../Clients/DshConfiguratorTests.cs.meta | 11 - website/docs/getting-started/clients.md | 2 +- website/docs/guides/client-configurators.md | 14 +- 12 files changed, 106 insertions(+), 2021 deletions(-) delete mode 100644 MCPForUnity/Editor/Helpers/DshBundleWriter.cs delete mode 100644 MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta delete mode 100644 MCPForUnity/Editor/Helpers/DshConfigHelper.cs delete mode 100644 MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs delete mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index ad34943069..54ebdb78b3 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -1,361 +1,127 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Text; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; -using Newtonsoft.Json.Linq; +using UnityEditor; namespace MCPForUnity.Editor.Clients.Configurators { /// - /// Configurator for the DeepSeek Harness (dsh CLI). DSH mounts external MCP - /// servers as Cordis rows for its @deepseek-ai/dsh-mcp-client plugin. This - /// configurator writes a configuration-only DSH bundle (manifest + patch) under - /// $DSH_HOME/bundles/unity-mcp/ and installs it into a named profile via - /// dsh plugin --profile <name> add <bundle-dir>. The bundle is - /// profile-scoped and removable through the plugin manager, unlike the root home - /// patch which applies to every profile. After a DSH reload/restart, tools surface - /// as mcp__unityMCP__<tool>. + /// DeepSeek Harness (dsh) has no client config file. MCP servers are mounted + /// through its plugin manager as a configuration-only bundle (a manifest plus a + /// cordis.patch.yml row for @deepseek-ai/dsh-mcp-client), so setup is + /// manual like the other UI-driven clients: the snippet below holds the two bundle + /// files and the dsh plugin add command that installs them. After a DSH + /// reload the tools surface as mcp__unityMCP__<tool>. From there DSH's + /// own plugin manager owns enable/disable and removal. /// public class DeepSeekHarnessConfigurator : McpClientConfiguratorBase { - private readonly string _dshHomeOverride; - private readonly string _profileOverride; + public const string ClientName = "DeepSeek Harness"; - /// - /// Injectable command runner for dsh CLI calls. Tests swap this to avoid - /// spawning real processes; production uses . - /// - internal static Func DshCommandRunner = RunDshCommand; + private const string ServerName = "unityMCP"; + private const string BundleName = "@local/unity-mcp-dsh"; - public DeepSeekHarnessConfigurator() : this(null, null) { } - - internal DeepSeekHarnessConfigurator(string dshHomeOverride, string profileOverride = null) - : base(new McpClient - { - name = "DeepSeek Harness", - windowsConfigPath = BuildConfigPath(dshHomeOverride, profileOverride), - macConfigPath = BuildConfigPath(dshHomeOverride, profileOverride), - linuxConfigPath = BuildConfigPath(dshHomeOverride, profileOverride) - }) + public DeepSeekHarnessConfigurator() : base(new McpClient { - _dshHomeOverride = dshHomeOverride; - _profileOverride = profileOverride; - } + name = ClientName, + windowsConfigPath = BundlePatchPath(), + macConfigPath = BundlePatchPath(), + linuxConfigPath = BundlePatchPath() + }) + { } - /// - /// Bundle install runs a CLI and must not happen automatically. The startup sweep - /// (StartupConfigRewrite) calls with - /// attemptAutoRewrite: true; if we auto-wrote here, merely opening a project - /// would install the bundle without the user asking. Bundle install is a user - /// action only. - /// public override bool SupportsAutoConfigure => false; - /// - /// DSH is "installed" when its home directory exists (created on first DSH run). - /// This is not derived from the config path because the config path lives inside - /// the managed bundle directory, which only exists after Configure runs. - /// - public override bool IsInstalled => Directory.Exists(ResolveDshHome(_dshHomeOverride)); - - // ------------------------------------------------------------------ paths - - /// Path to the managed bundle patch for the resolved home and profile. - private static string BuildConfigPath(string dshHomeOverride, string profileOverride) - { - return Path.Combine( - DshBundleWriter.GetBundleDir( - ResolveDshHome(dshHomeOverride), - ResolveDshProfile(profileOverride)), - "cordis.patch.yml"); - } - - /// Config path shown in the Setup window (the managed bundle patch). - public override string GetConfigPath() => CurrentOsPath(); - - /// - /// Resolves the DSH home directory: an explicit override wins, then the - /// DSH_HOME environment variable, then ~/.dsh (DSH's own default). - /// - internal static string ResolveDshHome(string dshHomeOverride) - { - if (!string.IsNullOrEmpty(dshHomeOverride)) return dshHomeOverride; + /// DSH is installed once it has run at least once (its home directory exists). + public override bool IsInstalled => Directory.Exists(DshHome()); - string envHome = Environment.GetEnvironmentVariable("DSH_HOME"); - if (!string.IsNullOrEmpty(envHome)) return envHome; + public override string GetConfigPath() => BundlePatchPath(); - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".dsh"); - } - - /// - /// Resolves the DSH profile name: an explicit override wins, then the - /// DSH_PROFILE environment variable, then "desktop". - /// - internal static string ResolveDshProfile(string profileOverride) - { - if (!string.IsNullOrEmpty(profileOverride)) return profileOverride; - - string envProfile = Environment.GetEnvironmentVariable("DSH_PROFILE"); - if (!string.IsNullOrEmpty(envProfile)) return envProfile; - - return "desktop"; - } - - // ------------------------------------------------------------------ status - - /// Configure when unconfigured, Unregister when configured. - public override string GetConfigureActionLabel() - => client.status == McpStatus.Configured ? "Unregister" : "Configure"; - - /// - /// Validates that the bundle is installed and its managed block matches the - /// current transport. Never calls , regardless of - /// — bundle install is a user action only. - /// When validation would want a rewrite, returns - /// (or ) instead. - /// public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { - try - { - string dshHome = ResolveDshHome(_dshHomeOverride); - string profile = ResolveDshProfile(_profileOverride); - - // Bundle files must exist. - string bundlePatchPath = Path.Combine( - DshBundleWriter.GetBundleDir(dshHome, profile), "cordis.patch.yml"); - if (!File.Exists(bundlePatchPath)) - { - client.SetStatus(McpStatus.NotConfigured); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - return client.status; - } - - // Profile package.json must list the bundle. - if (!ProfileListsBundle(dshHome, profile, DshBundleWriter.BundleName)) - { - client.SetStatus(McpStatus.NotConfigured); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - return client.status; - } - - // Parse the bundle's managed block and validate transport/url/source. - string fileText = File.ReadAllText(bundlePatchPath); - string block = DshConfigHelper.ExtractManagedBlock(fileText); - if (block == null) - { - client.SetStatus(McpStatus.IncorrectPath); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - return client.status; - } - - bool parsed = DshConfigHelper.TryParseManagedBlock( - block, out string transport, out string url, out _, out string[] args, - out _, out _); - if (!parsed) - { - client.SetStatus(McpStatus.IncorrectPath); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - return client.status; - } - - ConfiguredTransport current = HttpEndpointUtility.GetCurrentServerTransport(); - bool blockIsHttp = string.Equals(transport, "streamable-http", StringComparison.OrdinalIgnoreCase); - - if (blockIsHttp && current != Models.ConfiguredTransport.Stdio) - { - client.configuredTransport = HttpEndpointUtility.IsRemoteScope() - ? Models.ConfiguredTransport.HttpRemote - : Models.ConfiguredTransport.Http; - - if (UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl())) - { - client.SetStatus(McpStatus.Configured); - } - else - { - client.SetStatus(McpStatus.IncorrectPath); - } - return client.status; - } - - if (!blockIsHttp && current == Models.ConfiguredTransport.Stdio) - { - client.configuredTransport = Models.ConfiguredTransport.Stdio; - - string expected = GetExpectedPackageSourceForValidation(); - string configured = McpConfigurationHelper.ExtractUvxUrl(args); - if (!string.IsNullOrEmpty(configured) && !string.IsNullOrEmpty(expected) - && McpConfigurationHelper.PackageSourcesEqual(configured, expected)) - { - client.SetStatus(McpStatus.Configured); - } - else if (IsBetaPackageSource(configured) != IsBetaPackageSource(expected)) - { - client.SetStatus(McpStatus.VersionMismatch, IsBetaPackageSource(configured) - ? "Configured for prerelease server, but this package is stable. Re-configure to switch to stable." - : "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."); - } - else - { - client.SetStatus(McpStatus.IncorrectPath); - } - return client.status; - } - - // Block transport disagrees with the plugin's current transport preference. - client.configuredTransport = blockIsHttp - ? Models.ConfiguredTransport.Http - : Models.ConfiguredTransport.Stdio; - client.SetStatus(McpStatus.IncorrectPath); - } - catch (Exception ex) - { - client.SetStatus(McpStatus.Error, ex.Message); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - } - + client.SetStatus(McpStatus.NotConfigured, "DeepSeek Harness setup is manual; DSH's plugin manager owns the bundle."); return client.status; } - // ------------------------------------------------------------------ configure / unregister - public override void Configure() { - string dshHome = ResolveDshHome(_dshHomeOverride); - string profile = ResolveDshProfile(_profileOverride); - EnsureProfileExists(dshHome, profile); - string block = BuildBlock(); - string bundleDir = DshBundleWriter.WriteBundle(dshHome, profile, block); - - // Install into the named DSH profile through the plugin manager. - string manualCommand = $"dsh plugin --profile {profile} add {bundleDir}"; - (int exitCode, string output) = DshCommandRunner(new[] - { - "plugin", "--profile", profile, "add", bundleDir - }); - if (exitCode != 0) - { - throw new InvalidOperationException( - $"Failed to install DSH bundle via '{manualCommand}' (exit code {exitCode}): {output}\n" + - $"Run this command manually: {manualCommand}"); - } - - // Migration: remove any legacy root-patch managed block to prevent duplicate rows. - RemoveLegacyRootPatchBlock(dshHome); - - client.SetStatus(McpStatus.Configured); - client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + throw new InvalidOperationException( + "DeepSeek Harness setup is manual. Use the Manual Configuration snippet below: " + + "it holds the two bundle files and the 'dsh plugin add' command to run."); } - public override void Unregister() + public override IList GetInstallationSteps() => new List { - string dshHome = ResolveDshHome(_dshHomeOverride); - string profile = ResolveDshProfile(_profileOverride); - - // Removal must consider a disabled-but-installed bundle: DSH's plugin-manager - // toggle drops the entry from dsh.profile.bundles while the dependency stays, - // so skipping `remove` for it would delete the directory the dependency links. - if (ProfileDeclaresBundle(dshHome, profile, DshBundleWriter.BundleName)) - { - string manualCommand = $"dsh plugin --profile {profile} remove {DshBundleWriter.BundleName}"; - (int exitCode, string output) = DshCommandRunner(new[] - { - "plugin", "--profile", profile, "remove", DshBundleWriter.BundleName - }); - if (exitCode != 0) - { - // Keep the bundle: the profile still links this directory, and deleting - // the source now would leave a dangling symlink and break the bundle at - // the next reload. Report the failure so the user can retry. - throw new InvalidOperationException( - $"Failed to remove DSH bundle via '{manualCommand}' (exit code {exitCode}): {output}\n" + - $"Run this command manually: {manualCommand}"); - } - } - - DshBundleWriter.TryDeleteBundle(dshHome, profile); - RemoveLegacyRootPatchBlock(dshHome); - - client.SetStatus(McpStatus.NotConfigured); - client.configuredTransport = Models.ConfiguredTransport.Unknown; - } - - // ------------------------------------------------------------------ manual snippet / steps + "Install DeepSeek Harness and run any 'dsh' command once (creates ~/.dsh)", + "Create the two files shown in the Manual Configuration snippet below", + "Run the 'dsh plugin add' command from the snippet", + "Restart DeepSeek Harness; Unity tools appear as mcp__unityMCP__", + "DSH's plugin manager can enable/disable or remove the bundle afterwards" + }; - /// Renders the bundle files and the CLI install/remove commands. public override string GetManualSnippet() { try { - string dshHome = ResolveDshHome(_dshHomeOverride); - string profile = ResolveDshProfile(_profileOverride); - string bundleDir = DshBundleWriter.GetBundleDir(dshHome, profile); - + string bundleDir = BundleDir(); + string profile = ProfileName(); var sb = new StringBuilder(); - sb.AppendLine("# Mount the Unity MCP server in DeepSeek Harness as a native DSH bundle."); - sb.AppendLine("# Configure writes these files and runs 'dsh plugin add' to install them."); - sb.AppendLine("# $DSH_HOME defaults to ~/.dsh"); - sb.AppendLine(); - sb.AppendLine($"# --- {bundleDir}/package.json ---"); - sb.AppendLine(DshBundleWriter.ManifestJson.TrimEnd()); + sb.AppendLine("# DeepSeek Harness setup. $DSH_HOME defaults to ~/.dsh;"); + sb.AppendLine("# the profile below is 'desktop', swap it if you use another one."); sb.AppendLine(); - sb.AppendLine($"# --- {bundleDir}/cordis.patch.yml ---"); - sb.Append(BuildBlock()); + sb.AppendLine("# --- " + bundleDir + "/package.json ---"); + sb.AppendLine("{"); + sb.AppendLine(" \"name\": \"@local/unity-mcp-dsh\","); + sb.AppendLine(" \"version\": \"1.0.0\","); + sb.AppendLine(" \"private\": true,"); + sb.AppendLine(" \"type\": \"module\","); + sb.AppendLine(" \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }"); + sb.AppendLine("}"); sb.AppendLine(); - sb.AppendLine("# Install command (run by Configure):"); - sb.AppendLine($"# dsh plugin --profile {profile} add {bundleDir}"); - sb.AppendLine("# Remove command:"); - sb.AppendLine($"# dsh plugin --profile {profile} remove {DshBundleWriter.BundleName}"); + sb.AppendLine("# --- " + bundleDir + "/cordis.patch.yml ---"); + sb.Append(Row()); sb.AppendLine(); - sb.AppendLine("# Per-profile alternative (no bundle, not uninstallable via plugin manager):"); - sb.AppendLine("# Write the block above into $DSH_HOME/profiles//cordis.patch.yml"); - sb.AppendLine("# One-shot alternative (no persistence):"); - sb.AppendLine("# dsh web --patch "); + sb.AppendLine("# Install:"); + sb.AppendLine("# dsh plugin --profile " + ShellQuote(profile) + " add " + ShellQuote(bundleDir)); + sb.AppendLine("# Remove:"); + sb.AppendLine("# dsh plugin --profile " + ShellQuote(profile) + " remove " + ShellQuote(BundleName)); return sb.ToString(); } catch (Exception ex) { - return $"# error: {ex.Message}"; + return "# error: " + ex.Message; } } - /// Short user-facing steps shown under the manual snippet. - public override IList GetInstallationSteps() => new List - { - "Install DeepSeek Harness and run any 'dsh' command once (creates ~/.dsh)", - "Click Configure to write the bundle and install it into your DSH profile via dsh plugin add", - "Restart DSH (or reload its configuration) so the new MCP server mounts", - "Unity tools appear as mcp__unityMCP__" - }; - - // ------------------------------------------------------------------ internals - - /// Builds the managed block from the current transport preference and package settings. - private string BuildBlock() + /// Renders the patch row for the transport currently selected in Unity. + private static string Row() { - var transport = HttpEndpointUtility.GetCurrentServerTransport(); - bool useHttp = transport != Models.ConfiguredTransport.Stdio; - - string httpUrl = null; - string uvxCommand = null; - string[] uvxArgs = null; - string apiKey = null; - string systemRoot = null; + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + var sb = new StringBuilder(); + sb.AppendLine("# >>> MCP for Unity (unityMCP) managed block >>>"); + sb.AppendLine("- insert:"); + sb.AppendLine(" - id: unitymcp"); + sb.AppendLine(" name: '@deepseek-ai/dsh-mcp-client'"); + sb.AppendLine(" config:"); + sb.AppendLine(" serverName: " + ServerName); if (useHttp) { - httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + sb.AppendLine(" transport: streamable-http"); + sb.AppendLine(" url: " + YamlQuote(HttpEndpointUtility.GetMcpRpcUrl())); if (HttpEndpointUtility.IsRemoteScope()) { - apiKey = UnityEditor.EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + sb.AppendLine(" headers:"); + sb.AppendLine(" " + AuthConstants.ApiKeyHeader + ": " + YamlQuote(apiKey)); + } } } else @@ -366,296 +132,57 @@ private string BuildBlock() throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); } - var args = new List(); + sb.AppendLine(" transport: stdio"); + sb.AppendLine(" command: " + YamlQuote(uvxPath)); + sb.AppendLine(" args:"); foreach (string flag in AssetPathUtility.GetUvxDevFlagsList()) { - args.Add(flag); + sb.AppendLine(" - " + YamlQuote(flag)); } foreach (string arg in AssetPathUtility.GetBetaServerFromArgsList()) { - args.Add(arg); + sb.AppendLine(" - " + YamlQuote(arg)); } - args.Add(packageName); - args.Add("--transport"); - args.Add("stdio"); - uvxArgs = args.ToArray(); - uvxCommand = uvxPath; - - var platformService = MCPServiceLocator.Platform; - if (platformService.IsWindows()) + sb.AppendLine(" - " + YamlQuote(packageName)); + sb.AppendLine(" - " + YamlQuote("--transport")); + sb.AppendLine(" - " + YamlQuote("stdio")); + if (MCPServiceLocator.Platform.IsWindows()) { - systemRoot = platformService.GetSystemRoot(); + sb.AppendLine(" env:"); + sb.AppendLine(" SystemRoot: " + YamlQuote(MCPServiceLocator.Platform.GetSystemRoot())); } } - return DshConfigHelper.BuildUnityMcpBlock( - useHttp, - httpUrl, - uvxCommand, - uvxArgs, - string.IsNullOrEmpty(apiKey) ? null : AuthConstants.ApiKeyHeader, - apiKey, - systemRoot); - } - - /// - /// Fails fast when the target DSH profile does not exist. dsh plugin add - /// silently initializes a missing profile as a bare, unusable one, so a typo in - /// DSH_PROFILE would otherwise "succeed" into junk. - /// - private static void EnsureProfileExists(string dshHome, string profile) - { - string profilePackage = Path.Combine(dshHome, "profiles", profile, "package.json"); - if (File.Exists(profilePackage)) return; - - string profilesRoot = Path.Combine(dshHome, "profiles"); - string available = "(none)"; - if (Directory.Exists(profilesRoot)) - { - var names = new List(); - foreach (string dir in Directory.GetDirectories(profilesRoot)) - { - names.Add(Path.GetFileName(dir)); - } - if (names.Count > 0) available = string.Join(", ", names); - } - - throw new InvalidOperationException( - $"DSH profile '{profile}' does not exist ({profilePackage} missing). " + - $"Set DSH_PROFILE to an existing profile. Available profiles: {available}."); - } - - /// - /// Removes the managed block from the root home patch (legacy target). This - /// prevents duplicate rows when migrating from the root-patch approach to the - /// bundle approach. - /// - private static void RemoveLegacyRootPatchBlock(string dshHome) - { - string rootPatch = Path.Combine(dshHome, "cordis.patch.yml"); - if (!File.Exists(rootPatch)) return; - - string fileText = File.ReadAllText(rootPatch); - if (!DshConfigHelper.HasManagedBlock(fileText)) return; - - string updated = DshConfigHelper.RemoveManagedBlock(fileText); - McpConfigurationHelper.WriteAtomicFile(rootPatch, updated); - } - - /// - /// True when the profile declares the bundle anywhere: in dsh.profile.bundles - /// (enabled) or in dependencies (installed but possibly disabled through DSH's - /// plugin manager). Unregister uses this so a disabled-but-installed bundle still goes - /// through dsh plugin remove; status checks use - /// because a disabled bundle does not mount tools. - /// - private static bool ProfileDeclaresBundle(string dshHome, string profile, string bundleName) - { - string profilePackagePath = Path.Combine(dshHome, "profiles", profile, "package.json"); - if (!File.Exists(profilePackagePath)) return false; - - try - { - var json = JObject.Parse(File.ReadAllText(profilePackagePath)); - - var bundles = json["dsh"]?["profile"]?["bundles"] as JArray; - if (bundles != null) - { - foreach (var item in bundles) - { - if (string.Equals(item.ToString(), bundleName, StringComparison.Ordinal)) - return true; - } - } - - var dependencies = json["dependencies"] as JObject; - return dependencies != null && dependencies.Property(bundleName) != null; - } - catch - { - return false; - } - } - - /// - /// Checks whether the target profile's package.json lists the given bundle - /// name in dsh.profile.bundles. - /// - private static bool ProfileListsBundle(string dshHome, string profile, string bundleName) - { - string profilePackagePath = Path.Combine(dshHome, "profiles", profile, "package.json"); - if (!File.Exists(profilePackagePath)) return false; - - try - { - var json = JObject.Parse(File.ReadAllText(profilePackagePath)); - var bundles = json["dsh"]?["profile"]?["bundles"] as JArray; - if (bundles == null) return false; - foreach (var item in bundles) - { - if (string.Equals(item.ToString(), bundleName, StringComparison.Ordinal)) - return true; - } - return false; - } - catch - { - return false; - } + sb.AppendLine(" failOnStartupError: true"); + sb.AppendLine("# <<< MCP for Unity (unityMCP) <<<"); + return sb.ToString(); } - /// - /// Default dsh command runner using . Resolves the - /// launcher explicitly because on Windows dsh is a .cmd shim and - /// with UseShellExecute=false will not find it by - /// bare name. Captures stdout+stderr, enforces a 60-second timeout, and returns - /// -1 on any failure (including missing dsh) so callers can surface the - /// manual command. - /// - internal static (int ExitCode, string Output) RunDshCommand(string[] args) + /// DSH home: $DSH_HOME when set, else ~/.dsh. + private static string DshHome() { - try - { - if (!TryResolveDshExecutable(out string exePath, out bool isCmdShim)) - { - return (-1, "dsh executable not found on PATH. Install DeepSeek Harness " + - "or set DSH_CLI to the dsh launcher (.cmd on Windows)."); - } - - var psi = new ProcessStartInfo - { - FileName = isCmdShim ? "cmd.exe" : exePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - // cmd.exe /s /c "" strips the outer quotes and runs the rest as-is. - Arguments = isCmdShim - ? "/s /c \"" + QuoteArg(exePath) + " " + BuildArgumentsString(args) + "\"" - : BuildArgumentsString(args) - }; - - using (var process = Process.Start(psi)) - { - if (process == null) return (-1, "Failed to start dsh process"); + string envHome = Environment.GetEnvironmentVariable("DSH_HOME"); + if (!string.IsNullOrEmpty(envHome)) return envHome; - // Read both pipes concurrently and only then wait. Sequential ReadToEnd - // deadlocks when the child fills the other pipe's buffer first, and a - // hung child would block the read forever, past the timeout below. - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - if (!process.WaitForExit(60_000)) - { - try { process.Kill(); } catch { } - return (-1, "(dsh timed out after 60s)"); - } - process.WaitForExit(); - string output = stdoutTask.Result + stderrTask.Result; - return (process.ExitCode, output); - } - } - catch (Exception ex) - { - return (-1, $"Failed to run 'dsh': {ex.Message}"); - } + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrEmpty(home) ? string.Empty : Path.Combine(home, ".dsh"); } - /// - /// Finds the dsh launcher: a DSH_CLI environment override wins, - /// then each PATH entry is probed with platform-specific names - /// (dsh.cmd/dsh.bat/dsh.exe/dsh on Windows, - /// dsh elsewhere). Reports whether the hit is a cmd shim that must run - /// through cmd.exe. - /// - internal static bool TryResolveDshExecutable(out string path, out bool isCmdShim) + /// Target profile: DSH_PROFILE when set, else desktop. + private static string ProfileName() { - path = null; - isCmdShim = false; - - string overridePath = Environment.GetEnvironmentVariable("DSH_CLI"); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - path = overridePath; - isCmdShim = IsCmdShim(overridePath); - return true; - } - - bool windows = Path.DirectorySeparatorChar == '\\'; - string[] names = windows - ? new[] { "dsh.cmd", "dsh.bat", "dsh.exe", "dsh" } - : new[] { "dsh" }; - - foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator)) - { - string trimmed = dir.Trim().Trim('"'); - if (trimmed.Length == 0) continue; - foreach (string name in names) - { - string candidate = Path.Combine(trimmed, name); - if (!File.Exists(candidate)) continue; - path = candidate; - isCmdShim = IsCmdShim(candidate); - return true; - } - } - - return false; + string envProfile = Environment.GetEnvironmentVariable("DSH_PROFILE"); + return string.IsNullOrEmpty(envProfile) ? "desktop" : envProfile; } - /// True for .cmd/.bat launchers that must run through cmd.exe. - private static bool IsCmdShim(string path) - { - return path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase); - } + private static string BundleDir() => Path.Combine(DshHome(), "bundles", ProfileName(), "unity-mcp"); - /// Wraps an argument in double quotes, escaping embedded quotes and backslashes. - private static string QuoteArg(string arg) - { - // Standard CreateProcess/CommandLineToArgvW quoting: backslashes before a quote - // are doubled and embedded quotes escaped. cmd.exe metacharacters (& | < > ^) - // are literal inside double quotes, so quoting every argument also protects - // profile names like "R&D". '%' cannot be escaped this way (cmd expands %NAME% - // even inside quotes and leaves %% literal on the command line), so it is - // rejected in profile names instead. - string value = arg ?? string.Empty; - var sb = new StringBuilder(value.Length + 2); - sb.Append('"'); - int backslashes = 0; - foreach (char c in value) - { - if (c == '\\') { backslashes++; continue; } - if (c == '"') - { - sb.Append('\\', backslashes * 2 + 1); - sb.Append('"'); - backslashes = 0; - continue; - } - sb.Append('\\', backslashes); - backslashes = 0; - sb.Append(c); - } - sb.Append('\\', backslashes * 2); - sb.Append('"'); - return sb.ToString(); - } + private static string BundlePatchPath() => Path.Combine(BundleDir(), "cordis.patch.yml"); - /// Builds a process argument string with Windows-compatible quoting. - private static string BuildArgumentsString(string[] args) - { - if (args == null || args.Length == 0) return string.Empty; + /// Single-quotes a YAML scalar; embedded single quotes are doubled. + private static string YamlQuote(string value) => "'" + (value ?? string.Empty).Replace("'", "''") + "'"; - // Quote every argument, not only those with spaces: values may carry cmd.exe - // metacharacters (a profile named "R&D", a home path containing "&"), which - // are literal only inside double quotes. - var sb = new StringBuilder(); - for (int i = 0; i < args.Length; i++) - { - if (i > 0) sb.Append(' '); - sb.Append(QuoteArg(args[i])); - } - return sb.ToString(); - } + /// Double-quotes a shell argument so cmd.exe metacharacters stay literal. + private static string ShellQuote(string value) => "\"" + (value ?? string.Empty).Replace("\"", "\\\"") + "\""; } } diff --git a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs deleted file mode 100644 index 637329424b..0000000000 --- a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.IO; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Writes and removes a configuration-only DeepSeek Harness (DSH) bundle that mounts - /// the Unity MCP server. The bundle is a small directory under - /// $DSH_HOME/bundles/<dir> containing a package.json manifest and - /// the cordis.patch.yml patch block (produced by - /// ). It is installed into a specific - /// DSH profile through the DSH plugin manager - /// (dsh plugin --profile <name> add <bundle-dir>), making the row - /// profile-scoped and removable as a unit — unlike the root home patch, which applies - /// to every profile and is not uninstallable through the plugin manager. - /// - public static class DshBundleWriter - { - /// Bundle package name recorded in the manifest and used for remove. - public const string BundleName = "@local/unity-mcp-dsh"; - - /// Directory name under $DSH_HOME/bundles/. - public const string BundleDirName = "unity-mcp"; - - private const string ManifestFileName = "package.json"; - private const string PatchFileName = "cordis.patch.yml"; - - /// - /// The exact manifest content proven to install via DSH's plugin manager. - /// Must stay byte-identical to what the dsh plugin add flow expects. - /// - internal const string ManifestJson = - "{\n" + - " \"name\": \"@local/unity-mcp-dsh\",\n" + - " \"version\": \"1.0.0\",\n" + - " \"private\": true,\n" + - " \"type\": \"module\",\n" + - " \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }\n" + - "}\n"; - - /// - /// Writes (or overwrites) the bundle directory containing the manifest and the - /// given patch block. The bundle directory is entirely managed by us, so no marker - /// surgery is needed inside it — files are overwritten wholesale. Returns the - /// bundle directory path. The path is profile-qualified because - /// dsh plugin add symlinks this directory into the profile: sharing one - /// directory across profiles would let one profile's Unregister dangle another's - /// link. - /// - /// DSH home directory ($DSH_HOME or ~/.dsh). - /// DSH profile the bundle is installed into. - /// Full YAML block from . - public static string WriteBundle(string dshHome, string profile, string patchBlock) - { - if (string.IsNullOrEmpty(dshHome)) - throw new ArgumentException("dshHome is required", nameof(dshHome)); - if (patchBlock == null) - throw new ArgumentNullException(nameof(patchBlock)); - - string dir = GetBundleDir(dshHome, profile); - Directory.CreateDirectory(dir); - - File.WriteAllText(Path.Combine(dir, ManifestFileName), ManifestJson); - File.WriteAllText(Path.Combine(dir, PatchFileName), patchBlock); - - return dir; - } - - /// - /// Removes the bundle directory recursively. Returns true when the directory - /// existed and was deleted; false when it was already absent. Only the calling - /// profile's bundle is removed — other profiles keep their own directories. - /// - public static bool TryDeleteBundle(string dshHome, string profile) - { - if (string.IsNullOrEmpty(dshHome)) return false; - string dir = GetBundleDir(dshHome, profile); - if (!Directory.Exists(dir)) return false; - Directory.Delete(dir, true); - return true; - } - - /// - /// Path to the profile-qualified bundle directory that holds the manifest and patch. - /// Profile names are validated because the name flows into filesystem paths and into - /// the cmd.exe command line: '%' is rejected (cmd expands %NAME% patterns even - /// inside double quotes) and '"' breaks argument quoting. cmd.exe metacharacters such - /// as '&' are allowed because arguments are always double-quoted. - /// - public static string GetBundleDir(string dshHome, string profile) - { - if (string.IsNullOrWhiteSpace(profile)) - throw new ArgumentException("profile is required", nameof(profile)); - if (profile == "." || profile == ".." - || profile.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 - || profile.IndexOfAny(new[] { '/', '\\', '%', '"' }) >= 0) - { - throw new ArgumentException($"profile is not a safe directory name: '{profile}'", nameof(profile)); - } - return Path.Combine(dshHome, "bundles", profile, BundleDirName); - } - } -} diff --git a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta b/MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta deleted file mode 100644 index 2c1fab2dee..0000000000 --- a/MCPForUnity/Editor/Helpers/DshBundleWriter.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: da07114fdbe824c48a855104ffa23816 \ No newline at end of file diff --git a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs deleted file mode 100644 index 0dc7808320..0000000000 --- a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// DeepSeek Harness (DSH) specific configuration helpers. DSH mounts external MCP - /// servers as Cordis configuration rows for the @deepseek-ai/dsh-mcp-client - /// plugin, stored in a YAML patch layer (by default $DSH_HOME/cordis.patch.yml, - /// with $DSH_HOME defaulting to ~/.dsh). - /// - /// User patch files may contain arbitrary YAML features this package has no parser - /// for (e.g. !!js JS-expression tags, anchors, unrelated server rows). This - /// helper therefore manages one marker-fenced block and never parses or rewrites - /// anything outside it: upsert replaces only the fenced region, and status checks - /// parse only the fenced region's known shape. - /// - public static class DshConfigHelper - { - public const string ServerId = "unitymcp"; - public const string ServerName = "unityMCP"; - public const string PluginName = "@deepseek-ai/dsh-mcp-client"; - - public const string BeginMarker = "# >>> MCP for Unity (unityMCP) managed block - do not edit inside >>>"; - public const string EndMarker = "# <<< MCP for Unity (unityMCP) <<<"; - - /// - /// Builds the fenced YAML patch block that mounts the Unity MCP server in DSH. - /// All values are passed in so the method is pure and unit-testable. - /// - /// true writes a streamable-http row, false a stdio row. - /// Streamable HTTP MCP RPC endpoint (used when ). - /// uvx executable path (used for stdio). - /// stdio arguments after the command (dev flags, package source, package name, --transport stdio). - /// Optional request header name for HTTP auth (e.g. X-API-Key). - /// Optional request header value for HTTP auth. - /// SystemRoot env override for stdio children on Windows, or null. - public static string BuildUnityMcpBlock( - bool useHttp, - string httpUrl, - string uvxCommand, - string[] uvxArgs, - string apiKeyHeader, - string apiKey, - string windowsSystemRoot) - { - var lines = new List - { - BeginMarker, - "- insert:", - $" - id: {ServerId}", - $" name: '{PluginName}'", - " config:", - $" serverName: {ServerName}", - }; - - if (useHttp) - { - lines.Add(" transport: streamable-http"); - lines.Add($" url: {Quote(httpUrl ?? string.Empty)}"); - if (!string.IsNullOrEmpty(apiKeyHeader) && !string.IsNullOrEmpty(apiKey)) - { - lines.Add(" headers:"); - lines.Add($" {apiKeyHeader}: {Quote(apiKey)}"); - } - } - else - { - lines.Add(" transport: stdio"); - lines.Add($" command: {Quote(uvxCommand ?? string.Empty)}"); - lines.Add(" args:"); - foreach (string arg in uvxArgs ?? Array.Empty()) - { - lines.Add($" - {Quote(arg ?? string.Empty)}"); - } - - if (!string.IsNullOrEmpty(windowsSystemRoot)) - { - lines.Add(" env:"); - lines.Add($" SystemRoot: {Quote(windowsSystemRoot)}"); - } - } - - lines.Add(" failOnStartupError: true"); - lines.Add(EndMarker); - return string.Join("\n", lines) + "\n"; - } - - /// - /// Returns the rows between the fenced markers (exclusive), or null when no - /// complete block is present. Never reads content outside the markers. - /// - public static string ExtractManagedBlock(string fileText) - { - BlockRange? range = FindBlockRange(fileText); - if (range == null) return null; - - int contentStart = fileText.IndexOf('\n', range.Value.MarkerBegin); - if (contentStart < 0 || contentStart + 1 > range.Value.MarkerEnd) return string.Empty; - return fileText.Substring(contentStart + 1, range.Value.MarkerEnd - contentStart - 1); - } - - /// - /// Replaces the existing managed block in place, or appends the block at the end - /// of the document. All bytes outside the managed block are preserved verbatim. - /// - public static string UpsertManagedBlock(string fileText, string block) - { - if (block == null) throw new ArgumentNullException(nameof(block)); - string normalized = block.TrimEnd('\r', '\n') + "\n"; - - if (string.IsNullOrWhiteSpace(fileText)) - { - return normalized; - } - - BlockRange? range = FindBlockRange(fileText); - if (range == null) - { - string separator = fileText.EndsWith("\n") ? string.Empty : "\n"; - return fileText + separator + normalized; - } - - return fileText.Substring(0, range.Value.BlockStart) - + normalized - + fileText.Substring(range.Value.BlockEnd); - } - - /// - /// Removes the managed block, leaving the rest of the document untouched. - /// Returns the original text when no block is present. - /// - public static string RemoveManagedBlock(string fileText) - { - BlockRange? range = FindBlockRange(fileText); - if (range == null) return fileText; - - string before = fileText.Substring(0, range.Value.BlockStart); - string after = fileText.Substring(range.Value.BlockEnd); - - // Collapse a doubled blank line left behind by the removal. - if (before.EndsWith("\n\n") && after.StartsWith("\n", StringComparison.Ordinal)) - { - before = before.Substring(0, before.Length - 1); - } - - return before + after; - } - - /// True when the file already contains a complete managed block. - public static bool HasManagedBlock(string fileText) - { - return FindBlockRange(fileText) != null; - } - - /// - /// Parses the managed block emitted by . - /// Line-based by design: the block's shape is ours, so no YAML parser is needed - /// and foreign YAML can never confuse the result. - /// - public static bool TryParseManagedBlock( - string block, - out string transport, - out string url, - out string command, - out string[] args, - out string headerName, - out string headerValue) - { - transport = null; - url = null; - command = null; - args = null; - headerName = null; - headerValue = null; - - if (string.IsNullOrWhiteSpace(block)) return false; - - string[] lines = block.Replace("\r\n", "\n").Split('\n'); - var argList = new List(); - bool inArgs = false; - bool inHeaders = false; - int sectionIndent = -1; - - foreach (string rawLine in lines) - { - if (string.IsNullOrWhiteSpace(rawLine)) continue; - int indent = rawLine.Length - rawLine.TrimStart().Length; - string line = rawLine.Trim(); - - if (inArgs && indent > sectionIndent && line.StartsWith("- ", StringComparison.Ordinal)) - { - argList.Add(Unquote(line.Substring(2).Trim())); - continue; - } - - if (inHeaders && indent > sectionIndent && TrySplitKeyValue(line, out string hKey, out string hValue)) - { - if (headerName == null) - { - headerName = hKey; - headerValue = Unquote(hValue); - } - continue; - } - - inArgs = false; - inHeaders = false; - - if (line.StartsWith("transport:", StringComparison.Ordinal)) - { - transport = Unquote(line.Substring("transport:".Length).Trim()); - } - else if (line.StartsWith("url:", StringComparison.Ordinal)) - { - url = Unquote(line.Substring("url:".Length).Trim()); - } - else if (line.StartsWith("command:", StringComparison.Ordinal)) - { - command = Unquote(line.Substring("command:".Length).Trim()); - } - else if (line.StartsWith("args:", StringComparison.Ordinal)) - { - inArgs = true; - sectionIndent = indent; - } - else if (line.StartsWith("headers:", StringComparison.Ordinal)) - { - inHeaders = true; - sectionIndent = indent; - } - } - - args = argList.ToArray(); - return transport != null; - } - - /// Single-quotes a YAML scalar; embedded single quotes are doubled. - public static string Quote(string value) - { - return "'" + (value ?? string.Empty).Replace("'", "''") + "'"; - } - - /// Strips single quotes written by ; plain scalars pass through. - public static string Unquote(string value) - { - if (value == null) return null; - value = value.Trim(); - if (value.Length >= 2 && value[0] == '\'' && value[value.Length - 1] == '\'') - { - return value.Substring(1, value.Length - 2).Replace("''", "'"); - } - return value; - } - - private readonly struct BlockRange - { - /// Start of the begin-marker line (equal to MarkerBegin). - public readonly int BlockStart; - /// One past the end of the end-marker line (its newline included). - public readonly int BlockEnd; - /// Index of the first character of the begin marker. - public readonly int MarkerBegin; - /// Index of the first character of the end marker. - public readonly int MarkerEnd; - - public BlockRange(int blockStart, int blockEnd, int markerBegin, int markerEnd) - { - BlockStart = blockStart; - BlockEnd = blockEnd; - MarkerBegin = markerBegin; - MarkerEnd = markerEnd; - } - } - - /// Locates the fenced block; null when no complete marker pair exists. - private static BlockRange? FindBlockRange(string text) - { - if (string.IsNullOrEmpty(text)) return null; - - int markerBegin = IndexOfMarkerLine(text, BeginMarker, 0); - if (markerBegin < 0) return null; - - int markerEnd = IndexOfMarkerLine(text, EndMarker, markerBegin + BeginMarker.Length); - if (markerEnd < 0) return null; - - int blockEnd = text.IndexOf('\n', markerEnd); - blockEnd = blockEnd < 0 ? text.Length : blockEnd + 1; - return new BlockRange(markerBegin, blockEnd, markerBegin, markerEnd); - } - - /// Finds the next occurrence of marker at the start of its own line. - private static int IndexOfMarkerLine(string text, string marker, int startIndex) - { - int searchFrom = Math.Max(0, startIndex); - while (searchFrom <= text.Length - marker.Length) - { - int idx = text.IndexOf(marker, searchFrom, StringComparison.Ordinal); - if (idx < 0) return -1; - bool atLineStart = idx == 0 || text[idx - 1] == '\n'; - if (atLineStart) return idx; - searchFrom = idx + 1; - } - return -1; - } - - private static bool TrySplitKeyValue(string line, out string key, out string value) - { - key = null; - value = null; - int colon = line.IndexOf(':'); - if (colon <= 0) return false; - key = line.Substring(0, colon).Trim(); - value = line.Substring(colon + 1).Trim(); - return key.Length > 0 && value.Length > 0; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta b/MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta deleted file mode 100644 index dcef9ca6f0..0000000000 --- a/MCPForUnity/Editor/Helpers/DshConfigHelper.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 7c3f2a91d4b84e0f9a2c5d6e8f1a3b47 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 4a03ea11f8..61eccb13be 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -209,33 +209,6 @@ public static string ExtractUvxUrl(string[] args) return null; } - /// - /// Compares uvx package sources. Spec strings (e.g. mcpforunityserver>=0.0.0a0) - /// compare as text; path-like sources (local file: overrides, directories) - /// compare as normalized paths. must not be used for specs: - /// Path.GetFullPath throws on characters specs contain (>, =) - /// and its catch makes identical strings compare unequal. - /// - public static bool PackageSourcesEqual(string a, string b) - { - if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false; - a = a.Trim(); - b = b.Trim(); - - if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) return true; - - if (LooksLikeFilePath(a) && LooksLikeFilePath(b)) return PathsEqual(a, b); - return false; - } - - /// True for file: urls, drive-letter paths, or strings containing path separators. - private static bool LooksLikeFilePath(string source) - { - return source.StartsWith("file:", StringComparison.OrdinalIgnoreCase) - || source.IndexOfAny(new[] { '/', '\\' }) >= 0 - || (source.Length > 1 && source[1] == ':'); - } - public static bool PathsEqual(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs deleted file mode 100644 index 4b5cae201c..0000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.IO; -using MCPForUnity.Editor.Helpers; -using NUnit.Framework; - -namespace MCPForUnityTests.Editor.Clients -{ - /// - /// Covers : writing and deleting the configuration-only - /// DSH bundle directory that holds the manifest and the managed patch block. - /// - [TestFixture] - public class DshBundleWriterTests - { - private string _tempDir; - - /// Creates a fresh temp home directory per test. - [SetUp] - public void SetUp() - { - _tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - } - - /// Removes the temp home directory. - [TearDown] - public void TearDown() - { - try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } - } - - /// WriteBundle creates both manifest and patch at the profile-qualified path. - [Test] - public void WriteBundle_CreatesBothFiles() - { - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", - uvxCommand: null, uvxArgs: null, - apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); - - string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", block); - - Assert.IsTrue(File.Exists(Path.Combine(dir, "package.json"))); - Assert.IsTrue(File.Exists(Path.Combine(dir, "cordis.patch.yml"))); - Assert.AreEqual(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"), dir); - } - - /// The manifest matches the exact content DSH's plugin manager expects. - [Test] - public void WriteBundle_ExactManifestContent() - { - string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", "# test"); - - string manifest = File.ReadAllText(Path.Combine(dir, "package.json")); - StringAssert.Contains("\"name\": \"@local/unity-mcp-dsh\"", manifest); - StringAssert.Contains("\"version\": \"1.0.0\"", manifest); - StringAssert.Contains("\"private\": true", manifest); - StringAssert.Contains("\"type\": \"module\"", manifest); - StringAssert.Contains("\"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }", manifest); - } - - /// The patch file receives the block byte-for-byte. - [Test] - public void WriteBundle_PatchContentMatchesInput() - { - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", - uvxCommand: null, uvxArgs: null, - apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); - - string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", block); - - string written = File.ReadAllText(Path.Combine(dir, "cordis.patch.yml")); - Assert.AreEqual(block, written, "patch file must contain the block verbatim"); - } - - /// Reconfiguring overwrites the previous bundle contents. - [Test] - public void WriteBundle_OverwritesExistingFiles() - { - DshBundleWriter.WriteBundle(_tempDir, "desktop", "# first"); - string dir = DshBundleWriter.WriteBundle(_tempDir, "desktop", "# second"); - - string written = File.ReadAllText(Path.Combine(dir, "cordis.patch.yml")); - Assert.AreEqual("# second", written, "second write must overwrite the first"); - } - - /// TryDeleteBundle removes the directory and reports having done so. - [Test] - public void TryDeleteBundle_RemovesDir_ReturnsTrue() - { - DshBundleWriter.WriteBundle(_tempDir, "desktop", "# test"); - string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); - Assert.IsTrue(Directory.Exists(bundleDir)); - - bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir, "desktop"); - - Assert.IsTrue(deleted); - Assert.IsFalse(Directory.Exists(bundleDir)); - } - - /// TryDeleteBundle reports false when the bundle is already absent. - [Test] - public void TryDeleteBundle_AbsentDir_ReturnsFalse() - { - bool deleted = DshBundleWriter.TryDeleteBundle(_tempDir, "desktop"); - Assert.IsFalse(deleted); - } - - /// - /// Regression: bundle paths are profile-qualified because dsh plugin add symlinks - /// the directory — a shared path would let one profile's delete dangle another's link. - /// - [Test] - public void Bundles_AreIsolatedPerProfile() - { - DshBundleWriter.WriteBundle(_tempDir, "desktop", "# desktop-block"); - DshBundleWriter.WriteBundle(_tempDir, "web", "# web-block"); - - string desktopDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); - string webDir = Path.Combine(_tempDir, "bundles", "web", "unity-mcp"); - Assert.AreNotEqual(desktopDir, webDir); - - Assert.IsTrue(DshBundleWriter.TryDeleteBundle(_tempDir, "desktop")); - - Assert.IsFalse(Directory.Exists(desktopDir), "target profile bundle must be deleted"); - Assert.IsTrue(Directory.Exists(webDir), "other profile bundle must survive"); - Assert.AreEqual("# web-block", - File.ReadAllText(Path.Combine(webDir, "cordis.patch.yml")), - "other profile bundle content must be intact"); - } - - /// GetBundleDir rejects profile names that could escape the bundles directory. - [Test] - public void GetBundleDir_RejectsUnsafeProfileNames() - { - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, null)); - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, " ")); - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "..")); - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a/b")); - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a\\b")); - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a%b"), - "cmd.exe expands %NAME% patterns even inside quotes"); - Assert.Throws(() => DshBundleWriter.GetBundleDir(_tempDir, "a\"b"), - "double quotes break argument quoting"); - // cmd.exe metacharacters are safe: arguments are always double-quoted. - StringAssert.Contains("R&D", DshBundleWriter.GetBundleDir(_tempDir, "R&D")); - } - } -} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta deleted file mode 100644 index d784e34c97..0000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshBundleWriterTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5e50316070798b641ba4647a9adaf0a8 \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs deleted file mode 100644 index a99f552874..0000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs +++ /dev/null @@ -1,816 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using MCPForUnity.Editor.Clients.Configurators; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Models; -using MCPForUnity.Editor.Services; -using NUnit.Framework; - -namespace MCPForUnityTests.Editor.Clients -{ - /// - /// Covers the DeepSeek Harness integration: the marker-fenced YAML patch block helper - /// and the DeepSeekHarnessConfigurator that writes a configuration-only DSH bundle - /// and installs it via the DSH plugin manager (dsh plugin add). - /// - [TestFixture] - public class DshConfiguratorTests - { - private string _tempDir; - private Func _originalRunner; - - /// Creates a fresh temp DSH home and saves the default command runner. - [SetUp] - public void SetUp() - { - _tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - _originalRunner = DeepSeekHarnessConfigurator.DshCommandRunner; - } - - /// Restores the command runner and removes the temp DSH home. - [TearDown] - public void TearDown() - { - DeepSeekHarnessConfigurator.DshCommandRunner = _originalRunner; - try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } - } - - // ---------------------------------------------------------------- helper: build - - /// HTTP block emits the expected insert row and url. - [Test] - public void BuildBlock_Http_EmitsExpectedRows() - { - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", - uvxCommand: null, uvxArgs: null, - apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); - - StringAssert.Contains(DshConfigHelper.BeginMarker, block); - StringAssert.Contains(DshConfigHelper.EndMarker, block); - StringAssert.Contains("- insert:", block); - StringAssert.Contains($"- id: {DshConfigHelper.ServerId}", block); - StringAssert.Contains($"name: '{DshConfigHelper.PluginName}'", block); - StringAssert.Contains($"serverName: {DshConfigHelper.ServerName}", block); - StringAssert.Contains("transport: streamable-http", block); - StringAssert.Contains("url: 'http://127.0.0.1:8090/mcp'", block); - StringAssert.DoesNotContain("command:", block); - } - - /// HTTP block emits an auth header row when an API key is configured. - [Test] - public void BuildBlock_Http_WithApiKey_EmitsHeader() - { - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", - uvxCommand: null, uvxArgs: null, - apiKeyHeader: "X-API-Key", apiKey: "s3cret", windowsSystemRoot: null); - - StringAssert.Contains("headers:", block); - StringAssert.Contains("X-API-Key: 's3cret'", block); - } - - /// stdio block emits command, args, and the Windows SystemRoot env row. - [Test] - public void BuildBlock_Stdio_EmitsCommandArgsAndWindowsEnv() - { - string[] args = { "--from", "mcp-for-unity", "mcp-for-unity", "--transport", "stdio" }; - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: false, httpUrl: null, - uvxCommand: @"C:\tools\uvx.exe", uvxArgs: args, - apiKeyHeader: null, apiKey: null, - windowsSystemRoot: @"C:\Windows"); - - StringAssert.Contains("transport: stdio", block); - StringAssert.Contains(@"command: 'C:\tools\uvx.exe'", block); - StringAssert.Contains("- '--transport'", block); - StringAssert.Contains("- 'stdio'", block); - StringAssert.Contains(@"SystemRoot: 'C:\Windows'", block); - StringAssert.DoesNotContain("url:", block); - } - - // ---------------------------------------------------------------- helper: parse - - /// TryParseManagedBlock round-trips the HTTP block shape. - [Test] - public void TryParse_RoundTripsHttpBlock() - { - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: true, httpUrl: "http://127.0.0.1:8090/mcp", - uvxCommand: null, uvxArgs: null, - apiKeyHeader: "X-API-Key", apiKey: "it's-a-key", windowsSystemRoot: null); - - bool parsed = DshConfigHelper.TryParseManagedBlock( - block, out string transport, out string url, out string command, out string[] args, - out string headerName, out string headerValue); - - Assert.IsTrue(parsed); - Assert.AreEqual("streamable-http", transport); - Assert.AreEqual("http://127.0.0.1:8090/mcp", url); - Assert.IsNull(command); - Assert.IsTrue(args == null || args.Length == 0, "http block must have no args"); - Assert.AreEqual("X-API-Key", headerName); - Assert.AreEqual("it's-a-key", headerValue, "doubled single quotes must unescape"); - } - - /// TryParseManagedBlock round-trips stdio rows containing Windows paths. - [Test] - public void TryParse_RoundTripsStdioBlock_WithWindowsPaths() - { - string[] args = { "--from", @"E:\Dev\unity-mcp\Server", "mcp-for-unity", "--transport", "stdio" }; - string block = DshConfigHelper.BuildUnityMcpBlock( - useHttp: false, httpUrl: null, - uvxCommand: @"C:\Users\test user\.local\bin\uvx.exe", uvxArgs: args, - apiKeyHeader: null, apiKey: null, - windowsSystemRoot: @"C:\Windows"); - - bool parsed = DshConfigHelper.TryParseManagedBlock( - block, out string transport, out string url, out string command, out string[] parsedArgs, - out string headerName, out string headerValue); - - Assert.IsTrue(parsed); - Assert.AreEqual("stdio", transport); - Assert.IsNull(url); - Assert.AreEqual(@"C:\Users\test user\.local\bin\uvx.exe", command); - Assert.AreEqual(args, parsedArgs); - Assert.IsNull(headerName); - Assert.IsNull(headerValue, "env rows must not be mistaken for headers"); - } - - /// TryParseManagedBlock rejects an empty block. - [Test] - public void TryParse_RejectsEmptyBlock() - { - bool parsed = DshConfigHelper.TryParseManagedBlock( - "", out _, out _, out _, out _, out _, out _); - Assert.IsFalse(parsed); - } - - // ---------------------------------------------------------------- helper: upsert/remove - - /// Upsert into an empty file produces just the managed block. - [Test] - public void Upsert_IntoEmptyFile_ProducesValidListDocument() - { - string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); - string result = DshConfigHelper.UpsertManagedBlock(null, block); - - Assert.IsTrue(result.StartsWith(DshConfigHelper.BeginMarker), "file must remain a top-level YAML list"); - StringAssert.EndsWith("\n", result); - } - - /// Upserting the same block twice is idempotent. - [Test] - public void Upsert_Twice_IsIdempotent() - { - string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); - string once = DshConfigHelper.UpsertManagedBlock(null, block); - string twice = DshConfigHelper.UpsertManagedBlock(once, BuildHttpBlock("http://127.0.0.1:9090/mcp")); - - Assert.AreEqual(1, CountOccurrences(twice, DshConfigHelper.BeginMarker), - "re-configuring must replace the block, not append a second one"); - StringAssert.Contains("9090", twice); - StringAssert.DoesNotContain("8090", twice); - } - - /// Upsert preserves unrelated user patch content byte-for-byte. - [Test] - public void Upsert_PreservesForeignUserPatches_ByteForByte() - { - string userPatches = - "- insert:\n" + - " - id: mcp-memory\n" + - " name: '@deepseek-ai/dsh-mcp-client'\n" + - " config:\n" + - " serverName: memory\n" + - " transport: stdio\n" + - " command: my-memory-mcp\n" + - " env:\n" + - " MEMORY_TOKEN: !!js process.env.MEMORY_TOKEN\n" + - "\n"; - - string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); - string result = DshConfigHelper.UpsertManagedBlock(userPatches, block); - - StringAssert.StartsWith(userPatches, result, "user patch content must be preserved verbatim"); - Assert.AreEqual(1, CountOccurrences(result, DshConfigHelper.BeginMarker)); - } - - /// Upsert appends cleanly when the file lacks a trailing newline. - [Test] - public void Upsert_AppendsWithoutTrailingNewlineInput() - { - string existing = "- insert:\n - id: x\n name: pkg-x"; - string result = DshConfigHelper.UpsertManagedBlock(existing, BuildHttpBlock("http://127.0.0.1:8090/mcp")); - - StringAssert.StartsWith(existing + "\n", result); - } - - /// Remove deletes only the managed block. - [Test] - public void Remove_DeletesOnlyManagedBlock() - { - string userPatches = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; - string file = userPatches + BuildHttpBlock("http://127.0.0.1:8090/mcp"); - - string result = DshConfigHelper.RemoveManagedBlock(file); - - Assert.AreEqual(userPatches, result, "only the managed block may be removed"); - } - - /// Remove leaves text unchanged when no block exists. - [Test] - public void Remove_WithoutBlock_ReturnsTextUnchanged() - { - string text = "- insert: []\n"; - Assert.AreEqual(text, DshConfigHelper.RemoveManagedBlock(text)); - } - - /// Extract returns null without markers and the rows when markers exist. - [Test] - public void Extract_ReturnsNullWithoutMarkers_AndRowsWithMarkers() - { - Assert.IsNull(DshConfigHelper.ExtractManagedBlock("- insert: []\n")); - - string block = BuildHttpBlock("http://127.0.0.1:8090/mcp"); - string inner = DshConfigHelper.ExtractManagedBlock(block); - StringAssert.Contains("transport: streamable-http", inner); - StringAssert.DoesNotContain(DshConfigHelper.BeginMarker, inner); - } - - // ---------------------------------------------------------------- configurator wiring - - /// Config path points at the bundle patch inside the overridden DSH home. - [Test] - public void ConfigPath_UsesBundlePatchInsideOverrideHome() - { - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - string expected = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp", "cordis.patch.yml"); - Assert.AreEqual(expected, configurator.GetConfigPath()); - } - - /// ResolveDshHome precedence: explicit override, then DSH_HOME, then ~/.dsh. - [Test] - public void ResolveDshHome_PrefersOverride_ThenEnv_ThenDotDsh() - { - Assert.AreEqual(@"E:\custom\home", DeepSeekHarnessConfigurator.ResolveDshHome(@"E:\custom\home")); - - string previous = Environment.GetEnvironmentVariable("DSH_HOME"); - try - { - Environment.SetEnvironmentVariable("DSH_HOME", @"E:\env\home"); - Assert.AreEqual(@"E:\env\home", DeepSeekHarnessConfigurator.ResolveDshHome(null)); - - Environment.SetEnvironmentVariable("DSH_HOME", null); - string expectedHome = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dsh"); - Assert.AreEqual(expectedHome, DeepSeekHarnessConfigurator.ResolveDshHome(null)); - } - finally - { - Environment.SetEnvironmentVariable("DSH_HOME", previous); - } - } - - /// ResolveDshProfile precedence: explicit override, then DSH_PROFILE, then desktop. - [Test] - public void ResolveDshProfile_PrefersOverride_ThenEnv_ThenDesktop() - { - Assert.AreEqual("my-profile", DeepSeekHarnessConfigurator.ResolveDshProfile("my-profile")); - - string previous = Environment.GetEnvironmentVariable("DSH_PROFILE"); - try - { - Environment.SetEnvironmentVariable("DSH_PROFILE", "web"); - Assert.AreEqual("web", DeepSeekHarnessConfigurator.ResolveDshProfile(null)); - - Environment.SetEnvironmentVariable("DSH_PROFILE", null); - Assert.AreEqual("desktop", DeepSeekHarnessConfigurator.ResolveDshProfile(null)); - } - finally - { - Environment.SetEnvironmentVariable("DSH_PROFILE", previous); - } - } - - /// IsInstalled follows existence of the DSH home directory. - [Test] - public void IsInstalled_FollowsParentDirectory() - { - string missing = Path.Combine(_tempDir, "does-not-exist"); - Assert.IsFalse(new DeepSeekHarnessConfigurator(missing).IsInstalled); - Assert.IsTrue(new DeepSeekHarnessConfigurator(_tempDir).IsInstalled); - } - - /// A missing bundle reports NotConfigured and performs no writes or CLI calls. - [Test] - public void CheckStatus_MissingBundle_NotConfigured_NoAutoWrite() - { - bool seamCalled = false; - DeepSeekHarnessConfigurator.DshCommandRunner = args => - { - seamCalled = true; - return (0, ""); - }; - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var status = configurator.CheckStatus(attemptAutoRewrite: true); - - Assert.AreEqual(McpStatus.NotConfigured, status); - Assert.IsFalse(seamCalled, "CheckStatus must never invoke the dsh command seam"); - string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); - Assert.IsFalse(Directory.Exists(bundleDir), "CheckStatus must never write bundle files"); - } - - /// A bundle matching the current HTTP endpoint reports Configured. - [Test] - public void CheckStatus_HttpMatch_Configured() - { - WithHttpTransport(true, () => - { - string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); - WriteBundleFiles(_tempDir, BuildHttpBlock(expectedUrl)); - WriteProfileBundleEntry(_tempDir, "desktop"); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var status = configurator.CheckStatus(attemptAutoRewrite: false); - - Assert.AreEqual(McpStatus.Configured, status); - Assert.AreNotEqual(ConfiguredTransport.Unknown, configurator.ConfiguredTransport); - }); - } - - /// A stale HTTP url reports IncorrectPath without rewriting anything. - [Test] - public void CheckStatus_HttpMismatch_ReturnsIncorrectPath_WithoutRewrite() - { - WithHttpTransport(true, () => - { - string bundlePatch = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp", "cordis.patch.yml"); - WriteBundleFiles(_tempDir, BuildHttpBlock("http://127.0.0.1:1/mcp")); - WriteProfileBundleEntry(_tempDir, "desktop"); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var before = configurator.CheckStatus(attemptAutoRewrite: false); - Assert.AreEqual(McpStatus.IncorrectPath, before); - - var after = configurator.CheckStatus(attemptAutoRewrite: true); - Assert.AreEqual(McpStatus.IncorrectPath, after, "must not auto-rewrite"); - - string updated = File.ReadAllText(bundlePatch); - StringAssert.Contains("127.0.0.1:1", updated, "bundle must not have been rewritten"); - }); - } - - [Test] - public void CheckStatus_StdioMatch_Configured() - { - WithHttpTransport(false, () => - { - // The --from spec that Configure would really write, so the test matches - // in any environment (prerelease range, beta pin, or local override). - string expected = AssetPathUtility.GetMcpServerPackageSource(); - WriteProfileBundleEntry(_tempDir, "desktop"); - WriteBundleFiles(_tempDir, BuildStdioBlock(expected)); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var status = configurator.CheckStatus(attemptAutoRewrite: false); - - Assert.AreEqual(McpStatus.Configured, status, - "identical --from spec must validate (specs are text, not file paths)"); - }); - } - - [Test] - public void CheckStatus_StdioSpecMismatch_NotConfigured() - { - WithHttpTransport(false, () => - { - string expected = AssetPathUtility.GetMcpServerPackageSource(); - WriteProfileBundleEntry(_tempDir, "desktop"); - WriteBundleFiles(_tempDir, BuildStdioBlock(expected + ".x")); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var status = configurator.CheckStatus(attemptAutoRewrite: false); - - Assert.AreNotEqual(McpStatus.Configured, status, - "a different --from spec must not read as Configured"); - }); - } - - [Test] - public void PackageSourcesEqual_SpecsCompareAsText() - { - // Regression: PathsEqual compared specs as file paths, and Path.GetFullPath - // throws on '>' — so identical specs compared unequal and status was always - // IncorrectPath (masked only by auto-rewrite). - Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual( - "mcpforunityserver>=0.0.0a0", "mcpforunityserver>=0.0.0a0")); - Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual( - "mcpforunityserver>=0.0.0a0", " MCPforUnityServer>=0.0.0a0 ")); - Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual( - "mcpforunityserver>=0.0.0a0", "mcpforunityserver>=0.0.1a0")); - Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual(null, "x")); - Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual("x", " ")); - } - - [Test] - public void PackageSourcesEqual_PathsCompareNormalized() - { - Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual("file:C:\\dev\\Server", "file:C:\\dev\\Server")); - Assert.IsTrue(McpConfigurationHelper.PackageSourcesEqual("C:\\dev\\Server", "c:/dev/Server")); - Assert.IsFalse(McpConfigurationHelper.PackageSourcesEqual("C:\\dev\\Server", "C:\\dev\\Other")); - } - - /// Configure writes the bundle and invokes dsh plugin add through the seam. - [Test] - public void Configure_WritesBundleAndInvokesSeam() - { - WithHttpTransport(true, () => - { - string capturedArgs = null; - DeepSeekHarnessConfigurator.DshCommandRunner = args => - { - capturedArgs = string.Join(" ", args); - return (0, "ok"); - }; - - WriteProfilePackage(_tempDir, "desktop"); - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - - string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); - Assert.IsTrue(File.Exists(Path.Combine(bundleDir, "package.json")), "manifest must exist"); - Assert.IsTrue(File.Exists(Path.Combine(bundleDir, "cordis.patch.yml")), "patch must exist"); - - StringAssert.Contains("plugin", capturedArgs); - StringAssert.Contains("--profile", capturedArgs); - StringAssert.Contains("desktop", capturedArgs); - StringAssert.Contains("add", capturedArgs); - StringAssert.Contains(bundleDir, capturedArgs); - Assert.AreEqual(McpStatus.Configured, configurator.Status); - }); - } - - /// Configure removes a legacy managed block from the root home patch. - [Test] - public void Configure_MigrationRemovesLegacyRootPatchBlock() - { - WithHttpTransport(true, () => - { - string rootPatch = Path.Combine(_tempDir, "cordis.patch.yml"); - string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; - File.WriteAllText(rootPatch, userRow + BuildHttpBlock("http://127.0.0.1:1/mcp")); - - DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); - - WriteProfilePackage(_tempDir, "desktop"); - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - - string after = File.ReadAllText(rootPatch); - Assert.AreEqual(userRow, after, "legacy managed block must be removed from root patch"); - Assert.AreEqual(0, CountOccurrences(after, DshConfigHelper.BeginMarker)); - }); - } - - /// Configure and Unregister leave user patch rows untouched. - [Test] - public void Configure_ThenUnregister_LeavesUserPatchesIntact() - { - WithHttpTransport(true, () => - { - string rootPatch = Path.Combine(_tempDir, "cordis.patch.yml"); - string userRow = "- insert:\n - id: mcp-memory\n name: '@deepseek-ai/dsh-mcp-client'\n"; - File.WriteAllText(rootPatch, userRow); - - DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); - - WriteProfilePackage(_tempDir, "desktop"); - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - - Assert.AreEqual(McpStatus.Configured, configurator.Status); - Assert.AreEqual(userRow, File.ReadAllText(rootPatch), "root patch must be untouched when no legacy block exists"); - - configurator.Unregister(); - - Assert.AreEqual(McpStatus.NotConfigured, configurator.Status); - Assert.AreEqual(userRow, File.ReadAllText(rootPatch), "unregister must not touch user rows"); - Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp")), "bundle dir must be deleted"); - }); - } - - /// Regression: a failed dsh plugin remove must keep the bundle (no dangling link) and report the failure. - [Test] - public void Unregister_RemovalFailure_KeepsBundleAndThrows() - { - WithHttpTransport(true, () => - { - DeepSeekHarnessConfigurator.DshCommandRunner = args => - string.Join(" ", args).Contains("remove") ? (1, "remove failed") : (0, "ok"); - WriteProfilePackage(_tempDir, "desktop"); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - WriteProfileBundleEntry(_tempDir, "desktop"); - - Assert.Throws(() => configurator.Unregister()); - - Assert.IsTrue(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp")), - "bundle must be kept while the profile still links it"); - Assert.AreNotEqual(McpStatus.NotConfigured, configurator.Status, - "a failed removal must not report NotConfigured"); - }); - } - - /// Unregister deletes leftover bundle files without invoking the CLI when the profile does not list the bundle. - [Test] - public void Unregister_Unregistered_DeletesFilesWithoutRemove() - { - WithHttpTransport(true, () => - { - var calls = new List(); - DeepSeekHarnessConfigurator.DshCommandRunner = args => - { - calls.Add(string.Join(" ", args)); - return (0, "ok"); - }; - WriteProfilePackage(_tempDir, "desktop"); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - calls.Clear(); - - configurator.Unregister(); - - Assert.IsFalse(calls.Exists(c => c.Contains("remove")), - "CLI remove must not run when the profile does not list the bundle"); - Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"))); - Assert.AreEqual(McpStatus.NotConfigured, configurator.Status); - }); - } - - /// - /// Regression: a disabled-but-installed bundle (dependency present, absent from - /// dsh.profile.bundles after DSH's toggle) must still go through dsh plugin remove. - /// - [Test] - public void Unregister_DisabledButInstalled_StillRunsRemove() - { - WithHttpTransport(true, () => - { - var calls = new List(); - DeepSeekHarnessConfigurator.DshCommandRunner = args => - { - calls.Add(string.Join(" ", args)); - return (0, "ok"); - }; - WriteProfilePackage(_tempDir, "desktop"); - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - // Simulate the plugin-manager toggle OFF: the dependency stays installed - // while the entry disappears from dsh.profile.bundles. - WriteProfileDependencyOnly(_tempDir, "desktop"); - calls.Clear(); - - configurator.Unregister(); - - Assert.IsTrue(calls.Exists(c => c.Contains("remove") && c.Contains(DshBundleWriter.BundleName)), - "remove must run while the dependency is still declared"); - Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"))); - }); - } - - /// - /// Regression: unregistering one profile must not delete or dangle another - /// profile's bundle (dsh plugin add symlinks the bundle directory). - /// - [Test] - public void Unregister_OnlyRemovesTargetProfileBundle() - { - WithHttpTransport(true, () => - { - DeepSeekHarnessConfigurator.DshCommandRunner = _ => (0, "ok"); - - WriteProfilePackage(_tempDir, "desktop"); - WriteProfilePackage(_tempDir, "web"); - - var desktop = new DeepSeekHarnessConfigurator(_tempDir, "desktop"); - desktop.Configure(); - var web = new DeepSeekHarnessConfigurator(_tempDir, "web"); - web.Configure(); - - // Simulate the registration effect of the real `dsh plugin add`, which the - // stubbed seam does not perform. - WriteProfileBundleEntry(_tempDir, "desktop"); - WriteProfileBundleEntry(_tempDir, "web"); - - desktop.Unregister(); - - Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp")), - "target profile bundle must be deleted"); - Assert.IsTrue(Directory.Exists(Path.Combine(_tempDir, "bundles", "web", "unity-mcp")), - "other profile bundle must survive"); - Assert.AreEqual(McpStatus.Configured, web.CheckStatus(attemptAutoRewrite: false), - "other profile must stay configured"); - }); - } - - /// Unregister calls dsh plugin remove and deletes the bundle directory. - [Test] - public void Unregister_CallsRemoveAndDeletesBundle() - { - WithHttpTransport(true, () => - { - string capturedArgs = null; - DeepSeekHarnessConfigurator.DshCommandRunner = args => - { - capturedArgs = string.Join(" ", args); - return (0, "ok"); - }; - - WriteProfilePackage(_tempDir, "desktop"); - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - configurator.Configure(); - // Simulate the registration effect of the real `dsh plugin add`, which the - // stubbed seam does not perform; Unregister only runs `remove` when the - // profile lists the bundle. - WriteProfileBundleEntry(_tempDir, "desktop"); - string bundleDir = Path.Combine(_tempDir, "bundles", "desktop", "unity-mcp"); - Assert.IsTrue(Directory.Exists(bundleDir)); - - configurator.Unregister(); - - StringAssert.Contains("remove", capturedArgs); - StringAssert.Contains(DshBundleWriter.BundleName, capturedArgs); - Assert.IsFalse(Directory.Exists(bundleDir), "bundle directory must be deleted"); - }); - } - - /// Configure fails fast on a missing profile without creating it or calling the CLI. - [Test] - public void Configure_MissingProfile_ThrowsWithoutCreatingProfile() - { - WithHttpTransport(true, () => - { - bool runnerCalled = false; - DeepSeekHarnessConfigurator.DshCommandRunner = args => - { - runnerCalled = true; - return (0, "ok"); - }; - - var configurator = new DeepSeekHarnessConfigurator(_tempDir); - var ex = Assert.Throws(() => configurator.Configure()); - - StringAssert.Contains("desktop", ex.Message); - Assert.IsFalse(runnerCalled, "dsh CLI must not run when the profile is missing"); - Assert.IsFalse(Directory.Exists(Path.Combine(_tempDir, "profiles", "desktop")), - "guard must not create the profile"); - }); - } - - /// The resolver finds dsh.cmd on PATH and reports it as a cmd shim. - [Test] - public void TryResolveDshExecutable_FindsCmdShimOnPath() - { - if (Path.DirectorySeparatorChar != '\\') Assert.Ignore("cmd shim resolution is Windows-specific"); - - string shimDir = Path.Combine(_tempDir, "fakebin"); - Directory.CreateDirectory(shimDir); - string shim = Path.Combine(shimDir, "dsh.cmd"); - File.WriteAllText(shim, "@echo off\r\n"); - - string originalPath = Environment.GetEnvironmentVariable("PATH"); - string originalCli = Environment.GetEnvironmentVariable("DSH_CLI"); - try - { - Environment.SetEnvironmentVariable("DSH_CLI", null); - Environment.SetEnvironmentVariable("PATH", shimDir); - - Assert.IsTrue(DeepSeekHarnessConfigurator.TryResolveDshExecutable(out string path, out bool isCmdShim)); - Assert.AreEqual(shim, path); - Assert.IsTrue(isCmdShim, ".cmd must be reported as a cmd shim"); - } - finally - { - Environment.SetEnvironmentVariable("PATH", originalPath); - Environment.SetEnvironmentVariable("DSH_CLI", originalCli); - } - } - - /// RunDshCommand executes a .cmd shim through cmd.exe and forwards its arguments. - [Test] - public void RunDshCommand_RoutesCmdShimThroughCmdExe() - { - if (Path.DirectorySeparatorChar != '\\') Assert.Ignore("cmd shim routing is Windows-specific"); - - string shim = Path.Combine(_tempDir, "dsh.cmd"); - File.WriteAllText(shim, "@echo off\r\necho shim-ok %*\r\nexit /b 0\r\n"); - - string originalCli = Environment.GetEnvironmentVariable("DSH_CLI"); - try - { - Environment.SetEnvironmentVariable("DSH_CLI", shim); - var (exitCode, output) = DeepSeekHarnessConfigurator.RunDshCommand( - new[] { "plugin", "--profile", "R&D", "add", "C:\\bundle dir" }); - - Assert.AreEqual(0, exitCode); - StringAssert.Contains("shim-ok", output); - StringAssert.Contains("plugin", output); - // cmd.exe metacharacters must round-trip as data, not be interpreted. - StringAssert.Contains("R&D", output); - StringAssert.Contains("C:\\bundle dir", output); - } - finally - { - Environment.SetEnvironmentVariable("DSH_CLI", originalCli); - } - } - - // ---------------------------------------------------------------- helpers - - /// Builds an HTTP managed block for the given url. - private static string BuildHttpBlock(string url) - { - return DshConfigHelper.BuildUnityMcpBlock( - useHttp: true, httpUrl: url, - uvxCommand: null, uvxArgs: null, - apiKeyHeader: null, apiKey: null, windowsSystemRoot: null); - } - - /// Builds a stdio managed block with the given --from package spec. - private static string BuildStdioBlock(string fromSpec) - { - string[] uvxArgs = { "--from", fromSpec, "mcp-for-unity", "--transport", "stdio" }; - return DshConfigHelper.BuildUnityMcpBlock( - useHttp: false, httpUrl: null, - uvxCommand: "uvx.exe", uvxArgs: uvxArgs, - apiKeyHeader: null, apiKey: null, windowsSystemRoot: "C:\\WINDOWS"); - } - - /// Writes the bundle files for the default test profile. - private static void WriteBundleFiles(string dshHome, string patchBlock) - { - DshBundleWriter.WriteBundle(dshHome, "desktop", patchBlock); - } - - /// Creates a bare profile directory with a package.json (no bundle entry). - private static void WriteProfilePackage(string dshHome, string profile) - { - string profileDir = Path.Combine(dshHome, "profiles", profile); - Directory.CreateDirectory(profileDir); - File.WriteAllText(Path.Combine(profileDir, "package.json"), "{\n \"name\": \"test-profile\"\n}\n"); - } - - /// Creates a profile package.json declaring the bundle only as a dependency (disabled state). - private static void WriteProfileDependencyOnly(string dshHome, string profile) - { - string profileDir = Path.Combine(dshHome, "profiles", profile); - Directory.CreateDirectory(profileDir); - string packageJson = "{\n \"dependencies\": {\n \"" + DshBundleWriter.BundleName + "\": \"link:./somewhere\"\n }\n}\n"; - File.WriteAllText(Path.Combine(profileDir, "package.json"), packageJson); - } - - /// Creates a profile package.json that lists the bundle as installed. - private static void WriteProfileBundleEntry(string dshHome, string profile) - { - string profileDir = Path.Combine(dshHome, "profiles", profile); - Directory.CreateDirectory(profileDir); - string packageJson = "{\n \"dsh\": {\n \"profile\": {\n \"bundles\": [\"" + DshBundleWriter.BundleName + "\"]\n }\n }\n}\n"; - File.WriteAllText(Path.Combine(profileDir, "package.json"), packageJson); - } - - /// Runs a body under the given transport preference, restoring the previous one afterwards. - private static void WithHttpTransport(bool useHttp, Action body) - { - bool original = EditorConfigurationCache.Instance.UseHttpTransport; - try - { - EditorConfigurationCache.Instance.SetUseHttpTransport(useHttp); - body(); - } - finally - { - EditorConfigurationCache.Instance.SetUseHttpTransport(original); - } - } - - /// Counts non-overlapping occurrences of a substring. - private static int CountOccurrences(string text, string needle) - { - int count = 0; - int idx = 0; - while ((idx = text.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) - { - count++; - idx += needle.Length; - } - return count; - } - } -} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta deleted file mode 100644 index 9ab2c718a7..0000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/DshConfiguratorTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9b4e8d2f1a6c4e3b8d7f0a5c1b2e3f64 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/website/docs/getting-started/clients.md b/website/docs/getting-started/clients.md index 69cab79d25..83a34f9b98 100644 --- a/website/docs/getting-started/clients.md +++ b/website/docs/getting-started/clients.md @@ -27,7 +27,7 @@ MCP for Unity auto-configures every client the package detects on your machine. | **OpenClaw** | HTTP / stdio | yes | yes | yes | Requires `openclaw-mcp-bridge` plugin enabled. Follows MCP for Unity's transport choice. | | **Antigravity** | HTTP | yes | yes | varies | Requires an MCP toggle in Antigravity settings. | | **Pi** | stdio / HTTP | yes | yes | yes | Needs an MCP extension (Pi ships no MCP client of its own): `pi install npm:pi-mcp-adapter`. Writes the shared `~/.config/mcp/mcp.json`. | -| **DeepSeek Harness** | HTTP / stdio | no (manual Configure) | yes | yes | Installs a configuration-only DSH bundle (`$DSH_HOME/bundles//unity-mcp/`) into a DSH profile via `dsh plugin add`. Tools appear as `mcp__unityMCP__`. | +| **DeepSeek Harness** | HTTP / stdio | no (manual Configure) | yes | yes | Manual setup: the snippet holds a configuration-only DSH bundle (`$DSH_HOME/bundles//unity-mcp/`) installed with `dsh plugin add`. Tools appear as `mcp__unityMCP__`. | ## How to pick diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index dd6dcedfcb..c2f348062a 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -175,10 +175,9 @@ Some clients cannot be handled by the generic JSON configurator alone. ### DeepSeek Harness (native DSH bundle) -- Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, configured as Cordis rows — not a JSON/TOML config file. -- Configure writes a **configuration-only DSH bundle** to `$DSH_HOME/bundles//unity-mcp/` (the `DSH_HOME` environment variable wins, default `~/.dsh`) containing a `package.json` manifest (`dsh.bundle.patch` → `./cordis.patch.yml`) and the patch itself, then installs it into a DSH profile with `dsh plugin --profile add `. The bundle path is profile-qualified because `dsh plugin add` symlinks the directory into the profile — one shared directory would let one profile's Unregister dangle another profile's link. The profile is `desktop` by default; set `DSH_PROFILE` to target another profile. -- Installing as a bundle (instead of editing `$DSH_HOME/cordis.patch.yml`) keeps the row **profile-scoped and removable** through DSH's own plugin manager (`dsh plugin --profile remove @local/unity-mcp-dsh`). The root home patch applies to *every* profile and collides with rows from other sources, so it is only used for one-shot experiments (`dsh web --patch `) and migration cleanup. -- The row shape inside the bundle's `cordis.patch.yml` (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): +- Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, registered as a Cordis row — not a JSON/TOML config file. +- Setup is manual, like the other UI-driven clients. The snippet renders a configuration-only bundle (`$DSH_HOME/bundles//unity-mcp/` with a `package.json` manifest and a `cordis.patch.yml` row) plus the `dsh plugin add` / `dsh plugin remove` commands. After a DSH reload the tools surface as `mcp__unityMCP__`. +- The row shape (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): ```yaml - insert: - id: unitymcp @@ -187,11 +186,10 @@ Some clients cannot be handled by the generic JSON configurator alone. serverName: unityMCP transport: streamable-http # or stdio with command/args url: http://127.0.0.1:/mcp + failOnStartupError: true ``` -- Bundle contents are wholly managed (files are overwritten in place). `DshConfigHelper` (`MCPForUnity/Editor/Helpers/DshConfigHelper.cs`) still owns the block shape and any marker-fenced surgery on *foreign* patch files: the package has no YAML parser, and user patch files may contain YAML features this helper does not parse (e.g. `!!js` JS-expression tags, anchors) plus unrelated server rows that must never be corrupted. Configure/Unregister also migrate away a legacy managed block left in `$DSH_HOME/cordis.patch.yml` by earlier versions, so exactly one row exists. -- After a DSH reload/restart, Unity tools surface as `mcp__unityMCP__` (DSH's server-qualified MCP naming). -- Detection is fail-closed: DSH counts as installed only once it has run at least once (its home directory exists). Configure refuses to run when the target profile does not exist, because `dsh plugin add` silently initializes a missing profile as a bare, unusable one. -- Status checks never rewrite anything: bundle install is a user action only, so opening a project never modifies DSH configuration. +- Installing as a bundle (instead of editing `$DSH_HOME/cordis.patch.yml`, a global layer that collides with rows from other sources) keeps the row profile-scoped, and DSH's own plugin manager owns enable/disable and removal afterwards. +- Detection is fail-closed: DSH counts as installed only once it has run at least once (its home directory exists). --- From c7f62e46f3f80731fbb6c06957db7ff62d46d62a Mon Sep 17 00:00:00 2001 From: sobhanb-eth Date: Mon, 19 Oct 2026 12:45:41 +0200 Subject: [PATCH 11/11] docs(clients): note DSH desktop profile handling in the manual snippet The desktop profile is Electron-owned. Verified against the Desktop-installed dsh (0.2.1-rc.2): dsh plugin add/remove/ls on the desktop profile work (the documented Desktop flow), while --dump-config and boot are rejected with "managed exclusively by the Electron application". The snippet and steps now say to quit DeepSeek Harness before running the commands and to pick another profile name when using a standalone npm dsh. --- .../Clients/Configurators/DeepSeekHarnessConfigurator.cs | 8 +++++--- website/docs/guides/client-configurators.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs index 54ebdb78b3..f03161e823 100644 --- a/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs +++ b/MCPForUnity/Editor/Clients/Configurators/DeepSeekHarnessConfigurator.cs @@ -59,7 +59,7 @@ public override void Configure() { "Install DeepSeek Harness and run any 'dsh' command once (creates ~/.dsh)", "Create the two files shown in the Manual Configuration snippet below", - "Run the 'dsh plugin add' command from the snippet", + "Quit DeepSeek Harness, run the 'dsh plugin add' command from the snippet, then reopen it", "Restart DeepSeek Harness; Unity tools appear as mcp__unityMCP__", "DSH's plugin manager can enable/disable or remove the bundle afterwards" }; @@ -71,8 +71,10 @@ public override string GetManualSnippet() string bundleDir = BundleDir(); string profile = ProfileName(); var sb = new StringBuilder(); - sb.AppendLine("# DeepSeek Harness setup. $DSH_HOME defaults to ~/.dsh;"); - sb.AppendLine("# the profile below is 'desktop', swap it if you use another one."); + sb.AppendLine("# DeepSeek Harness setup. $DSH_HOME defaults to ~/.dsh."); + sb.AppendLine("# 'desktop' is the Desktop app's own profile: quit DeepSeek Harness, run the"); + sb.AppendLine("# commands below, then reopen it. With a standalone dsh (npm install), pick"); + sb.AppendLine("# another profile name instead; 'desktop' is reserved for the Desktop app."); sb.AppendLine(); sb.AppendLine("# --- " + bundleDir + "/package.json ---"); sb.AppendLine("{"); diff --git a/website/docs/guides/client-configurators.md b/website/docs/guides/client-configurators.md index c2f348062a..f981d45544 100644 --- a/website/docs/guides/client-configurators.md +++ b/website/docs/guides/client-configurators.md @@ -176,7 +176,7 @@ Some clients cannot be handled by the generic JSON configurator alone. ### DeepSeek Harness (native DSH bundle) - Uses a custom configurator (`DeepSeekHarnessConfigurator`) because DeepSeek Harness (`dsh`) consumes MCP servers through its `@deepseek-ai/dsh-mcp-client` plugin, registered as a Cordis row — not a JSON/TOML config file. -- Setup is manual, like the other UI-driven clients. The snippet renders a configuration-only bundle (`$DSH_HOME/bundles//unity-mcp/` with a `package.json` manifest and a `cordis.patch.yml` row) plus the `dsh plugin add` / `dsh plugin remove` commands. After a DSH reload the tools surface as `mcp__unityMCP__`. +- Setup is manual, like the other UI-driven clients. The snippet renders a configuration-only bundle (`$DSH_HOME/bundles//unity-mcp/` with a `package.json` manifest and a `cordis.patch.yml` row) plus the `dsh plugin add` / `dsh plugin remove` commands. Run them with DSH closed (its `desktop` profile is Electron-managed) using the Desktop-installed `dsh`, then reopen it. After a DSH reload the tools surface as `mcp__unityMCP__`. - The row shape (confirmed against DSH's `mcp-memory` guide and plugin-manager patch schema): ```yaml - insert: