| // Copyright 2025 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package mcp |
| |
| import ( |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| |
| jsonrpc2 "golang.org/x/tools/internal/jsonrpc2_v2" |
| ) |
| |
| // A ServerResource associates a Resource with its handler. |
| type ServerResource struct { |
| Resource *Resource |
| Handler ResourceHandler |
| } |
| |
| // A ResourceHandler is a function that reads a resource. |
| // If it cannot find the resource, it should return the result of calling [ResourceNotFoundError]. |
| type ResourceHandler func(context.Context, *ServerSession, *ReadResourceParams) (*ReadResourceResult, error) |
| |
| // ResourceNotFoundError returns an error indicating that a resource being read could |
| // not be found. |
| func ResourceNotFoundError(uri string) error { |
| return &jsonrpc2.WireError{ |
| Code: codeResourceNotFound, |
| Message: "Resource not found", |
| Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)), |
| } |
| } |
| |
| // The error code to return when a resource isn't found. |
| // See https://modelcontextprotocol.io/specification/2025-03-26/server/resources#error-handling |
| // However, the code they chose in in the wrong space |
| // (see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/509). |
| // so we pick a different one, arbirarily for now (until they fix it). |
| // The immediate problem is that jsonprc2 defines -32002 as "server closing". |
| const codeResourceNotFound = -31002 |
| |
| // readFileResource reads from the filesystem at a URI relative to dirFilepath, respecting |
| // the roots. |
| // dirFilepath and rootFilepaths are absolute filesystem paths. |
| func readFileResource(rawURI, dirFilepath string, rootFilepaths []string) ([]byte, error) { |
| uriFilepath, err := computeURIFilepath(rawURI, dirFilepath, rootFilepaths) |
| if err != nil { |
| return nil, err |
| } |
| |
| var data []byte |
| err = withFile(dirFilepath, uriFilepath, func(f *os.File) error { |
| var err error |
| data, err = io.ReadAll(f) |
| return err |
| }) |
| if os.IsNotExist(err) { |
| err = ResourceNotFoundError(rawURI) |
| } |
| return data, err |
| } |
| |
| // computeURIFilepath returns a path relative to dirFilepath. |
| // The dirFilepath and rootFilepaths are absolute file paths. |
| func computeURIFilepath(rawURI, dirFilepath string, rootFilepaths []string) (string, error) { |
| // We use "file path" to mean a filesystem path. |
| uri, err := url.Parse(rawURI) |
| if err != nil { |
| return "", err |
| } |
| if uri.Scheme != "file" { |
| return "", fmt.Errorf("URI is not a file: %s", uri) |
| } |
| if uri.Path == "" { |
| // A more specific error than the one below, to catch the |
| // common mistake "file://foo". |
| return "", errors.New("empty path") |
| } |
| // The URI's path is interpreted relative to dirFilepath, and in the local filesystem. |
| // It must not try to escape its directory. |
| uriFilepathRel, err := filepath.Localize(strings.TrimPrefix(uri.Path, "/")) |
| if err != nil { |
| return "", fmt.Errorf("%q cannot be localized: %w", uriFilepathRel, err) |
| } |
| |
| // Check roots, if there are any. |
| if len(rootFilepaths) > 0 { |
| // To check against the roots, we need an absolute file path, not relative to the directory. |
| // uriFilepath is local, so the joined path is under dirFilepath. |
| uriFilepathAbs := filepath.Join(dirFilepath, uriFilepathRel) |
| rootOK := false |
| // Check that the requested file path is under some root. |
| // Since both paths are absolute, that's equivalent to filepath.Rel constructing |
| // a local path. |
| for _, rootFilepathAbs := range rootFilepaths { |
| if rel, err := filepath.Rel(rootFilepathAbs, uriFilepathAbs); err == nil && filepath.IsLocal(rel) { |
| rootOK = true |
| break |
| } |
| } |
| if !rootOK { |
| return "", fmt.Errorf("URI path %q is not under any root", uriFilepathAbs) |
| } |
| } |
| return uriFilepathRel, nil |
| } |
| |
| // fileRoots transforms the Roots obtained from the client into absolute paths on |
| // the local filesystem. |
| // TODO(jba): expose this functionality to user ResourceHandlers, |
| // so they don't have to repeat it. |
| func fileRoots(rawRoots []*Root) ([]string, error) { |
| var fileRoots []string |
| for _, r := range rawRoots { |
| fr, err := fileRoot(r) |
| if err != nil { |
| return nil, err |
| } |
| fileRoots = append(fileRoots, fr) |
| } |
| return fileRoots, nil |
| } |
| |
| // fileRoot returns the absolute path for Root. |
| func fileRoot(root *Root) (_ string, err error) { |
| defer func() { |
| if err != nil { |
| err = fmt.Errorf("root %q: %w", root.URI, err) |
| } |
| }() |
| |
| // Convert to absolute file path. |
| rurl, err := url.Parse(root.URI) |
| if err != nil { |
| return "", err |
| } |
| if rurl.Scheme != "file" { |
| return "", errors.New("not a file URI") |
| } |
| if rurl.Path == "" { |
| // A more specific error than the one below, to catch the |
| // common mistake "file://foo". |
| 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)) |
| if !filepath.IsAbs(fileRoot) { |
| return "", errors.New("not an absolute path") |
| } |
| return fileRoot, nil |
| } |