internal/mcp: add the FileResourceHandler method

Provide secure reading of files on the local filesystem.

There are subtleties in path manipulation on Windows that I don't fully
understand, so I defer that work for now.

Change-Id: Ic11f4617dde4ac0b31ccd5b0a772bb2f56db026f
Reviewed-on: https://go-review.googlesource.com/c/tools/+/673196
Reviewed-by: Robert Findley <rfindley@google.com>
Reviewed-by: Sam Thanawalla <samthanawalla@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-13 15:38:08 -04:00
commit 8361df724d

View file

@ -10,6 +10,7 @@ import (
"fmt"
"iter"
"net/url"
"path/filepath"
"slices"
"sync"
@ -103,34 +104,6 @@ func (s *Server) RemoveTools(names ...string) {
}
}
// 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
// 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)
// A ServerResource associates a Resource with its handler.
type ServerResource struct {
Resource *Resource
Handler ResourceHandler
}
// AddResource adds the given resource to the server and associates it with
// a [ResourceHandler], which will be called when the client calls [ClientSession.ReadResource].
// If a resource with the same URI already exists, this one replaces it.
@ -247,6 +220,49 @@ func (s *Server) readResource(ctx context.Context, ss *ServerSession, params *Re
return res, nil
}
// FileResourceHandler returns a ReadResourceHandler that reads paths using dir as
// a base directory.
// It honors client roots and protects against path traversal attacks.
//
// The dir argument should be a filesystem path. It need not be absolute, but
// that is recommended to avoid a dependency on the current working directory (the
// check against client roots is done with an absolute path). If dir is not absolute
// and the current working directory is unavailable, FileResourceHandler panics.
//
// Lexical path traversal attacks, where the path has ".." elements that escape dir,
// are always caught. Go 1.24 and above also protects against symlink-based attacks,
// where symlinks under dir lead out of the tree.
func (s *Server) FileResourceHandler(dir string) ResourceHandler {
// Convert dir to an absolute path.
dirFilepath, err := filepath.Abs(dir)
if err != nil {
panic(err)
}
return func(ctx context.Context, ss *ServerSession, params *ReadResourceParams) (_ *ReadResourceResult, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("reading resource %s: %w", params.URI, err)
}
}()
// TODO: use a memoizing API here.
rootRes, err := ss.ListRoots(ctx, nil)
if err != nil {
return nil, fmt.Errorf("listing roots: %w", err)
}
roots, err := fileRoots(rootRes.Roots)
if err != nil {
return nil, err
}
data, err := readFileResource(params.URI, dirFilepath, roots)
if err != nil {
return nil, err
}
// TODO(jba): figure out mime type.
return &ReadResourceResult{Contents: NewBlobResourceContents(params.URI, "text/plain", data)}, nil
}
}
// Run runs the server over the given transport, which must be persistent.
//
// Run blocks until the client terminates the connection.