diff --git a/mcp/resource.go b/mcp/resource.go index 66c47f9c..aece8262 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 36ed3f1a..8ced53a5 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")