Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/TALXIS.CLI.MCP/McpPathNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 == "~")
Expand Down
56 changes: 56 additions & 0 deletions tests/TALXIS.CLI.Tests/MCP/McpPathNormalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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()
{
Expand Down
32 changes: 31 additions & 1 deletion tests/TALXIS.CLI.Tests/MCP/RootsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
{
Expand Down