diff --git a/src/TALXIS.CLI.MCP/McpPathNormalizer.cs b/src/TALXIS.CLI.MCP/McpPathNormalizer.cs index 4583549..a557f2e 100644 --- a/src/TALXIS.CLI.MCP/McpPathNormalizer.cs +++ b/src/TALXIS.CLI.MCP/McpPathNormalizer.cs @@ -15,6 +15,11 @@ internal static string ExpandHomeRelativePath(string path, bool allowFileUriLoca if (string.IsNullOrWhiteSpace(path)) return path; + // Normalize Windows file URI local drive paths like "/c:/project" early. + var normalizedPath = TryNormalizeWindowsFileUriDrivePath(path); + if (normalizedPath != null) + return normalizedPath; + var suffixStart = GetHomeRelativeSuffixStart(path, allowFileUriLocalPathHome); if (suffixStart < 0) return path; @@ -31,6 +36,19 @@ internal static string ExpandHomeRelativePath(string path, bool allowFileUriLoca return segments.Length == 0 ? home : Path.Combine([home, .. segments]); } + // Drops the leading slash from file URI local paths like /c:/project. + // This is a mechanical Uri.LocalPath normalization, not a semantic rewrite. + private static string? TryNormalizeWindowsFileUriDrivePath(string path) + { + if (OperatingSystem.IsWindows() && path.Length >= 3 + && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':') + { + return path[1..]; + } + + return null; + } + private static int GetHomeRelativeSuffixStart(string path, bool allowFileUriLocalPathHome) { if (path == "~") diff --git a/tests/TALXIS.CLI.Tests/MCP/McpPathNormalizerTests.cs b/tests/TALXIS.CLI.Tests/MCP/McpPathNormalizerTests.cs index 86cfc8f..1cb7c2c 100644 --- a/tests/TALXIS.CLI.Tests/MCP/McpPathNormalizerTests.cs +++ b/tests/TALXIS.CLI.Tests/MCP/McpPathNormalizerTests.cs @@ -23,6 +23,38 @@ public void NormalizeOperationalPath_HomeRelativeInput_ResolvesAgainstUserProfil Assert.Equal(expected, result); } + [Theory] + [InlineData("C:/~")] + [InlineData("C:\\~")] + [InlineData("C:/~/Sources/project")] + [InlineData("C:\\~\\Sources\\project")] + [InlineData("c:/~")] + [InlineData("c:/~/Sources/project")] + [InlineData("c:\\~\\Sources\\project")] + public void NormalizeOperationalPath_DriveQualifiedTilde_RemainsFilesystemPath(string input) + { + var result = McpPathNormalizer.NormalizeOperationalPath(input); + + Assert.Equal(Path.GetFullPath(input), result); + } + + [Theory] + [InlineData("C:/~folder", false)] + [InlineData("C:\\~folder", false)] + [InlineData("c:/~folder", false)] + [InlineData("c:\\~folder", false)] + [InlineData("/C:/~folder", true)] + [InlineData("/c:/~folder", true)] + public void NormalizeOperationalPath_NonDelimitedDriveQualifiedTilde_RemainsFilesystemPath(string input, bool isFileUriLocalDrivePath) + { + var result = McpPathNormalizer.NormalizeOperationalPath(input); + var expected = OperatingSystem.IsWindows() && isFileUriLocalDrivePath + ? Path.GetFullPath(input[1..]) + : Path.GetFullPath(input); + + Assert.Equal(expected, result); + } + [Theory] [InlineData("/~/Sources/project")] [InlineData("\\~\\Sources\\project")] @@ -38,6 +70,30 @@ public void NormalizeOperationalPath_FileUriLocalHomePath_ResolvesAgainstUserPro Assert.Equal(expected, result); } + [Theory] + [InlineData("/C:/Users/project")] + [InlineData("/c:/Users/project")] + public void NormalizeOperationalPath_FileUriDrivePath_NormalizesLeadingSlash(string input) + { + var result = McpPathNormalizer.NormalizeOperationalPath(input); + var expected = OperatingSystem.IsWindows() + ? Path.GetFullPath(input[1..]) + : Path.GetFullPath(input); + Assert.Equal(expected, result); + } + + [Fact] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public void NormalizeOperationalPath_VsCodeLowercaseDriveUri_IsValidOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + var localPath = "/c:/Users/example-user/Sources/project"; + var result = McpPathNormalizer.NormalizeOperationalPath(localPath, allowFileUriLocalPathHome: true); + Assert.True(Path.IsPathRooted(result)); + Assert.DoesNotContain("c:\\c:", result, StringComparison.OrdinalIgnoreCase); + Assert.Equal(Path.GetFullPath("c:/Users/example-user/Sources/project"), result); + } + [Fact] public void NormalizeOperationalPath_AbsoluteUnixPath_RemainsAbsolute() { diff --git a/tests/TALXIS.CLI.Tests/MCP/RootsServiceTests.cs b/tests/TALXIS.CLI.Tests/MCP/RootsServiceTests.cs index f58d912..09558e1 100644 --- a/tests/TALXIS.CLI.Tests/MCP/RootsServiceTests.cs +++ b/tests/TALXIS.CLI.Tests/MCP/RootsServiceTests.cs @@ -32,7 +32,25 @@ public void ConvertFileUri_WindowsLowercaseDrive_ReturnsNormalisedPath() // VS Code on Windows sends lowercase drive letters var result = RootsService.ConvertFileUriToPath("file:///c:/Users/project"); Assert.NotNull(result); - Assert.EndsWith("c:/Users/project", result.Replace('\\', '/')); + Assert.True(Path.IsPathFullyQualified(result), $"Expected fully-qualified path but got: {result}"); + + var normalised = result.Replace('\\', '/'); + Assert.DoesNotContain("c:/c:/", normalised, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Users/project", normalised, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public void ConvertFileUri_LowercaseDriveWorkspaceRoot_DoesNotDuplicateDrive() + { + if (!OperatingSystem.IsWindows()) return; + + var result = RootsService.ConvertFileUriToPath("file:///c:/Users/example-user/Sources/my-agent-team"); + Assert.NotNull(result); + Assert.True(Path.IsPathFullyQualified(result)); + Assert.DoesNotContain(@"c:\c:", result, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(Path.Combine("Users", "example-user", "Sources", "my-agent-team"), result, + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -79,6 +97,18 @@ public void ConvertFileUri_HomeRelativePath_ResolvesAgainstUserProfile() Assert.Equal(expected, result); } + [Theory] + [InlineData("file:///C:/~/Sources/project", "C:/~/Sources/project", "/C:/~/Sources/project")] + [InlineData("file:///c:/~/Sources/project", "c:/~/Sources/project", "/c:/~/Sources/project")] + public void ConvertFileUri_DriveQualifiedHome_RemainsFilesystemPath(string uri, string windowsPath, string nonWindowsPath) + { + var result = RootsService.ConvertFileUriToPath(uri); + + Assert.NotNull(result); + var expectedInput = OperatingSystem.IsWindows() ? windowsPath : nonWindowsPath; + Assert.Equal(Path.GetFullPath(expectedInput), result); + } + [Fact] public void ConvertFileUri_ResultIsFullPath() {