Implement sampling (the CreateMessage method). Change-Id: Ie44a671dd62831122b25cd7386b5bfcdece7ff60 Reviewed-on: https://go-review.googlesource.com/c/tools/+/673176 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Robert Findley <rfindley@google.com>
150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-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"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
)
|
|
|
|
// 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)),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|