From d85cdaa979dd3e5ab11bf1f2bacc1191b8dbe6e7 Mon Sep 17 00:00:00 2001 From: Akshita <110122283+akshita317@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:27:22 +0530 Subject: [PATCH] mcp: accept Windows drive letter file roots fileRoot turns the client's roots into absolute file paths for FileResourceHandler. On Windows a root for a drive letter path is a URI such as file:///C:/Users/me/project (RFC 8089, Appendix E.2), whose path is /C:/Users/me/project. fileRoot converted that to \C:\Users\me\project, which is not an absolute path on Windows, so every such root failed with "not an absolute path", and with it every read through FileResourceHandler once the client had any roots. Drop the slash before a drive letter on Windows. VS Code's form, file:///c%3A/..., decodes to the same path. TestFileRoot is no longer skipped on Windows and gets drive letter cases there; they fail without the change. TestFileURIPath covers the conversion for both Windows and other systems on any platform. --- mcp/resource.go | 18 ++++++++++++++- mcp/resource_test.go | 52 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/mcp/resource.go b/mcp/resource.go index 66c47f9c4..aece8262d 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" @@ -186,13 +187,28 @@ func fileRoot(root *Root) (_ string, err error) { return "", errors.New("empty path") } // We don't want Localize here: we want an absolute path, which is not local. - fileRoot := filepath.Clean(filepath.FromSlash(rurl.Path)) + fileRoot := filepath.Clean(filepath.FromSlash(fileURIPath(rurl.Path, runtime.GOOS))) if !filepath.IsAbs(fileRoot) { return "", errors.New("not an absolute path") } return fileRoot, nil } +// fileURIPath returns the path of a file URI in the form used by goos. +// On Windows, the path of a file URI for a drive letter path starts with a +// slash, as in file:///C:/dir (RFC 8089, Appendix E.2), which is dropped so +// that the result is the absolute path C:/dir. +func fileURIPath(uriPath, goos string) string { + if goos == "windows" && len(uriPath) >= 3 && uriPath[0] == '/' && uriPath[2] == ':' && isASCIILetter(uriPath[1]) { + return uriPath[1:] + } + return uriPath +} + +func isASCIILetter(c byte) bool { + return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' +} + // Matches reports whether the receiver's uri template matches the uri. func (sr *serverResourceTemplate) Matches(uri string) bool { tmpl, err := uritemplate.New(sr.resourceTemplate.URITemplate) diff --git a/mcp/resource_test.go b/mcp/resource_test.go index 36ed3f1a4..8ced53a5d 100644 --- a/mcp/resource_test.go +++ b/mcp/resource_test.go @@ -12,23 +12,35 @@ import ( ) func TestFileRoot(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("TODO: fix for Windows") - } - - for _, tt := range []struct { + type test struct { uri string want string wantErr string // error must contain this string - }{ - {uri: "file:///foo", want: "/foo"}, - {uri: "file:///foo/bar", want: "/foo/bar"}, - {uri: "file:///foo/../bar", want: "/bar"}, - {uri: "file:/foo", want: "/foo"}, + } + tests := []test{ {uri: "http:///foo", wantErr: "not a file"}, {uri: "file://foo", wantErr: "empty path"}, {uri: ":", wantErr: "missing protocol scheme"}, - } { + } + if runtime.GOOS == "windows" { + tests = append(tests, + test{uri: "file:///C:/foo", want: `C:\foo`}, + test{uri: "file:///c:/foo/bar", want: `c:\foo\bar`}, + test{uri: "file:///C:/foo/../bar", want: `C:\bar`}, + test{uri: "file:/C:/foo", want: `C:\foo`}, + // VS Code percent-encodes the colon after the drive letter. + test{uri: "file:///c%3A/foo", want: `c:\foo`}, + test{uri: "file:///foo", wantErr: "not an absolute path"}, + ) + } else { + tests = append(tests, + test{uri: "file:///foo", want: "/foo"}, + test{uri: "file:///foo/bar", want: "/foo/bar"}, + test{uri: "file:///foo/../bar", want: "/bar"}, + test{uri: "file:/foo", want: "/foo"}, + ) + } + for _, tt := range tests { got, err := fileRoot(&Root{URI: tt.uri}) if err != nil { if tt.wantErr == "" { @@ -47,6 +59,24 @@ func TestFileRoot(t *testing.T) { } } +func TestFileURIPath(t *testing.T) { + for _, tt := range []struct { + uriPath, goos, want string + }{ + {"/C:/foo", "windows", "C:/foo"}, + {"/c:/foo", "windows", "c:/foo"}, + {"/C:", "windows", "C:"}, + {"/foo", "windows", "/foo"}, + {"/1:/foo", "windows", "/1:/foo"}, + {"/C:/foo", "linux", "/C:/foo"}, + {"/foo", "linux", "/foo"}, + } { + if got := fileURIPath(tt.uriPath, tt.goos); got != tt.want { + t.Errorf("fileURIPath(%q, %q) = %q, want %q", tt.uriPath, tt.goos, got, tt.want) + } + } +} + func TestComputeURIFilepath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("TODO: fix for Windows")