internal/mcp: implement resource templates
Provide support for resource templates on the client and server. Adding, removing and listing resource templates looks just like those operations on resources. Although the spec provides no guidance on the unique ID for resource templates, we follow most existing SDKs, as well as the parallel with resources, and index on the URI template rather than the name. As the spec recommends, the server verifies an incoming URI in a ReadResource request against its list of resources and resource templates, to make sure the URI came from one of them. The main technical challenge here is the need to check that a URI template could have produced a particular URI. I wrote a very simplified version of this check. At some point we should switch to github.com/yosida95/uritemplate/v3. Change-Id: I1e34dd01c8f2171123c68c045ad62f5e85c05e7c Reviewed-on: https://go-review.googlesource.com/c/tools/+/680158 Auto-Submit: Jonathan Amsterdam <jba@google.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Robert Findley <rfindley@google.com>
This commit is contained in:
parent
d9a7859c3b
commit
c1524fe934
12 changed files with 262 additions and 25 deletions
|
|
@ -328,6 +328,11 @@ func (cs *ClientSession) ListResources(ctx context.Context, params *ListResource
|
|||
return handleSend[*ListResourcesResult](ctx, cs, methodListResources, params)
|
||||
}
|
||||
|
||||
// ListResourceTemplates lists the resource templates that are currently available on the server.
|
||||
func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) {
|
||||
return handleSend[*ListResourceTemplatesResult](ctx, cs, methodListResourceTemplates, params)
|
||||
}
|
||||
|
||||
// ReadResource ask the server to read a resource and return its contents.
|
||||
func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) {
|
||||
return handleSend[*ReadResourceResult](ctx, cs, methodReadResource, params)
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ var declarations = config{
|
|||
Fields: config{"Params": {Name: "ListResourcesParams"}},
|
||||
},
|
||||
"ListResourcesResult": {},
|
||||
"ListResourceTemplatesRequest": {
|
||||
Name: "-",
|
||||
Fields: config{"Params": {Name: "ListResourceTemplatesParams"}},
|
||||
},
|
||||
"ListResourceTemplatesResult": {},
|
||||
"ListRootsRequest": {
|
||||
Name: "-",
|
||||
Fields: config{"Params": {Name: "ListRootsParams"}},
|
||||
|
|
@ -152,6 +157,11 @@ var declarations = config{
|
|||
Name: "-",
|
||||
Fields: config{"Params": {Name: "ResourceListChangedParams"}},
|
||||
},
|
||||
"ResourceTemplate": {},
|
||||
"ResourceTemplateListChangedNotification": {
|
||||
Name: "-",
|
||||
Fields: config{"Params": {Name: "ResourceTemplateListChangedParams"}},
|
||||
},
|
||||
"Role": {},
|
||||
"Root": {},
|
||||
"RootsListChangedNotification": {
|
||||
|
|
|
|||
|
|
@ -237,13 +237,30 @@ func TestEndToEnd(t *testing.T) {
|
|||
t.Errorf("resources/list mismatch (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
template := &ResourceTemplate{
|
||||
Name: "rt",
|
||||
MIMEType: "text/template",
|
||||
URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/'
|
||||
}
|
||||
st := &ServerResourceTemplate{ResourceTemplate: template, Handler: readHandler}
|
||||
s.AddResourceTemplates(st)
|
||||
tres, err := cs.ListResourceTemplates(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff([]*ResourceTemplate{template}, tres.ResourceTemplates); diff != "" {
|
||||
t.Errorf("resources/list mismatch (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
uri string
|
||||
mimeType string // "": not found; "text/plain": resource; "text/template": template
|
||||
fail bool // non-nil error returned
|
||||
}{
|
||||
{"file:///info.txt", "text/plain"},
|
||||
{"file:///fail.txt", ""},
|
||||
// TODO(jba): add resource template cases when we implement them
|
||||
{"file:///info.txt", "text/plain", false},
|
||||
{"file:///fail.txt", "", false},
|
||||
{"file:///template.txt", "text/template", false},
|
||||
{"file:///../private.txt", "", true}, // not found: escaping disallowed
|
||||
} {
|
||||
rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri})
|
||||
if err != nil {
|
||||
|
|
@ -251,18 +268,22 @@ func TestEndToEnd(t *testing.T) {
|
|||
if tt.mimeType != "" {
|
||||
t.Errorf("%s: not found but expected it to be", tt.uri)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("reading %s: %v", tt.uri, err)
|
||||
} else if !tt.fail {
|
||||
t.Errorf("%s: unexpected error %v", tt.uri, err)
|
||||
}
|
||||
} else if g, w := len(rres.Contents), 1; g != w {
|
||||
t.Errorf("got %d contents, wanted %d", g, w)
|
||||
} else {
|
||||
c := rres.Contents[0]
|
||||
if got := c.URI; got != tt.uri {
|
||||
t.Errorf("got uri %q, want %q", got, tt.uri)
|
||||
}
|
||||
if got := c.MIMEType; got != tt.mimeType {
|
||||
t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType)
|
||||
if tt.fail {
|
||||
t.Errorf("%s: unexpected success", tt.uri)
|
||||
} else if g, w := len(rres.Contents), 1; g != w {
|
||||
t.Errorf("got %d contents, wanted %d", g, w)
|
||||
} else {
|
||||
c := rres.Contents[0]
|
||||
if got := c.URI; got != tt.uri {
|
||||
t.Errorf("got uri %q, want %q", got, tt.uri)
|
||||
}
|
||||
if got := c.MIMEType; got != tt.mimeType {
|
||||
t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,32 @@ type ListPromptsResult struct {
|
|||
func (x *ListPromptsResult) GetMeta() *Meta { return &x.Meta }
|
||||
func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor }
|
||||
|
||||
type ListResourceTemplatesParams struct {
|
||||
// This property is reserved by the protocol to allow clients and servers to
|
||||
// attach additional metadata to their responses.
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
// An opaque token representing the current pagination position. If provided,
|
||||
// the server should return results starting after this cursor.
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ListResourceTemplatesParams) GetMeta() *Meta { return &x.Meta }
|
||||
func (x *ListResourceTemplatesParams) cursorPtr() *string { return &x.Cursor }
|
||||
|
||||
// The server's response to a resources/templates/list request from the client.
|
||||
type ListResourceTemplatesResult struct {
|
||||
// This property is reserved by the protocol to allow clients and servers to
|
||||
// attach additional metadata to their responses.
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
// An opaque token representing the pagination position after the last returned
|
||||
// result. If present, there may be more results available.
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
ResourceTemplates []*ResourceTemplate `json:"resourceTemplates"`
|
||||
}
|
||||
|
||||
func (x *ListResourceTemplatesResult) GetMeta() *Meta { return &x.Meta }
|
||||
func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor }
|
||||
|
||||
type ListResourcesParams struct {
|
||||
// This property is reserved by the protocol to allow clients and servers to
|
||||
// attach additional metadata to their responses.
|
||||
|
|
@ -473,6 +499,27 @@ type ResourceListChangedParams struct {
|
|||
|
||||
func (x *ResourceListChangedParams) GetMeta() *Meta { return &x.Meta }
|
||||
|
||||
// A template description for resources available on the server.
|
||||
type ResourceTemplate struct {
|
||||
// Optional annotations for the client.
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
// A description of what this template is for.
|
||||
//
|
||||
// This can be used by clients to improve the LLM's understanding of available
|
||||
// resources. It can be thought of like a "hint" to the model.
|
||||
Description string `json:"description,omitempty"`
|
||||
// The MIME type for all resources that match this template. This should only be
|
||||
// included if all resources matching this template have the same type.
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
// A human-readable name for the type of resource this template refers to.
|
||||
//
|
||||
// This can be used by clients to populate UI elements.
|
||||
Name string `json:"name"`
|
||||
// A URI template (according to RFC 6570) that can be used to construct resource
|
||||
// URIs.
|
||||
URITemplate string `json:"uriTemplate"`
|
||||
}
|
||||
|
||||
// The sender or recipient of messages and data in a conversation.
|
||||
type Role string
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
|
|
@ -25,7 +26,14 @@ type ServerResource struct {
|
|||
Handler ResourceHandler
|
||||
}
|
||||
|
||||
// A ServerResourceTemplate associates a ResourceTemplate with its handler.
|
||||
type ServerResourceTemplate struct {
|
||||
ResourceTemplate *ResourceTemplate
|
||||
Handler ResourceHandler
|
||||
}
|
||||
|
||||
// A ResourceHandler is a function that reads a resource.
|
||||
// It will be called when the client calls [ClientSession.ReadResource].
|
||||
// If it cannot find the resource, it should return the result of calling [ResourceNotFoundError].
|
||||
type ResourceHandler func(context.Context, *ServerSession, *ReadResourceParams) (*ReadResourceResult, error)
|
||||
|
||||
|
|
@ -145,3 +153,69 @@ func fileRoot(root *Root) (_ string, err error) {
|
|||
}
|
||||
return fileRoot, nil
|
||||
}
|
||||
|
||||
// Matches reports whether the receiver's uri template matches the uri.
|
||||
// TODO: use "github.com/yosida95/uritemplate/v3"
|
||||
func (sr *ServerResourceTemplate) Matches(uri string) bool {
|
||||
re, err := uriTemplateToRegexp(sr.ResourceTemplate.URITemplate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return re.MatchString(uri)
|
||||
}
|
||||
|
||||
func uriTemplateToRegexp(uriTemplate string) (*regexp.Regexp, error) {
|
||||
pat := uriTemplate
|
||||
var b strings.Builder
|
||||
b.WriteByte('^')
|
||||
seen := map[string]bool{}
|
||||
for len(pat) > 0 {
|
||||
literal, rest, ok := strings.Cut(pat, "{")
|
||||
b.WriteString(regexp.QuoteMeta(literal))
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
expr, rest, ok := strings.Cut(rest, "}")
|
||||
if !ok {
|
||||
return nil, errors.New("missing '}'")
|
||||
}
|
||||
pat = rest
|
||||
if strings.ContainsRune(expr, ',') {
|
||||
return nil, errors.New("can't handle commas in expressions")
|
||||
}
|
||||
if strings.ContainsRune(expr, ':') {
|
||||
return nil, errors.New("can't handle prefix modifiers in expressions")
|
||||
}
|
||||
if len(expr) > 0 && expr[len(expr)-1] == '*' {
|
||||
return nil, errors.New("can't handle explode modifiers in expressions")
|
||||
}
|
||||
|
||||
// These sets of valid characters aren't accurate.
|
||||
// See https://datatracker.ietf.org/doc/html/rfc6570.
|
||||
var re, name string
|
||||
first := byte(0)
|
||||
if len(expr) > 0 {
|
||||
first = expr[0]
|
||||
}
|
||||
switch first {
|
||||
default:
|
||||
// {var} doesn't match slashes. (It should also fail to match other characters,
|
||||
// but this simplified implementation doesn't handle that.)
|
||||
re = `[^/]*`
|
||||
name = expr
|
||||
case '+':
|
||||
// {+var} matches anything, even slashes
|
||||
re = `.*`
|
||||
name = expr[1:]
|
||||
case '#', '.', '/', ';', '?', '&':
|
||||
return nil, fmt.Errorf("prefix character %c unsupported", first)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("can't handle duplicate name %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
b.WriteString(re)
|
||||
}
|
||||
b.WriteByte('$')
|
||||
return regexp.Compile(b.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,3 +112,25 @@ func TestReadFileResource(t *testing.T) {
|
|||
t.Errorf("got %q, want %q", g, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateMatch(t *testing.T) {
|
||||
uri := "file:///path/to/file"
|
||||
for _, tt := range []struct {
|
||||
template string
|
||||
want bool
|
||||
}{
|
||||
{"file:///{}/{a}/{b}", true},
|
||||
{"file:///{a}/{b}", false},
|
||||
{"file:///{+path}", true},
|
||||
{"file:///{a}/{+path}", true},
|
||||
} {
|
||||
re, err := uriTemplateToRegexp(tt.template)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tt.template, err)
|
||||
}
|
||||
got := re.MatchString(uri)
|
||||
if got != tt.want {
|
||||
t.Errorf("%s: got %t, want %t", tt.template, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type Server struct {
|
|||
prompts *featureSet[*ServerPrompt]
|
||||
tools *featureSet[*ServerTool]
|
||||
resources *featureSet[*ServerResource]
|
||||
resourceTemplates *featureSet[*ServerResourceTemplate]
|
||||
sessions []*ServerSession
|
||||
sendingMethodHandler_ MethodHandler[*ServerSession]
|
||||
receivingMethodHandler_ MethodHandler[*ServerSession]
|
||||
|
|
@ -79,6 +80,7 @@ func NewServer(name, version string, opts *ServerOptions) *Server {
|
|||
prompts: newFeatureSet(func(p *ServerPrompt) string { return p.Prompt.Name }),
|
||||
tools: newFeatureSet(func(t *ServerTool) string { return t.Tool.Name }),
|
||||
resources: newFeatureSet(func(r *ServerResource) string { return r.Resource.URI }),
|
||||
resourceTemplates: newFeatureSet(func(t *ServerResourceTemplate) string { return t.ResourceTemplate.URITemplate }),
|
||||
sendingMethodHandler_: defaultSendingMethodHandler[*ServerSession],
|
||||
receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession],
|
||||
}
|
||||
|
|
@ -126,10 +128,9 @@ func (s *Server) RemoveTools(names ...string) {
|
|||
func() bool { return s.tools.remove(names...) })
|
||||
}
|
||||
|
||||
// AddResources 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.
|
||||
// AddResource panics if a resource URI is invalid or not absolute (has an empty scheme).
|
||||
// AddResources adds the given resources to the server.
|
||||
// If a resource with the same URI already exists, it is replaced.
|
||||
// AddResources panics if a resource URI is invalid or not absolute (has an empty scheme).
|
||||
func (s *Server) AddResources(resources ...*ServerResource) {
|
||||
// Only notify if something could change.
|
||||
if len(resources) == 0 {
|
||||
|
|
@ -158,6 +159,31 @@ func (s *Server) RemoveResources(uris ...string) {
|
|||
func() bool { return s.resources.remove(uris...) })
|
||||
}
|
||||
|
||||
// AddResourceTemplates adds the given resource templates to the server.
|
||||
// If a resource template with the same URI template already exists, it will be replaced.
|
||||
// AddResourceTemplates panics if a URI template is invalid or not absolute (has an empty scheme).
|
||||
func (s *Server) AddResourceTemplates(templates ...*ServerResourceTemplate) {
|
||||
// Only notify if something could change.
|
||||
if len(templates) == 0 {
|
||||
return
|
||||
}
|
||||
s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{},
|
||||
func() bool {
|
||||
for _, t := range templates {
|
||||
// TODO: check template validity.
|
||||
s.resourceTemplates.add(t)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveResourceTemplates removes the resource templates with the given URI templates.
|
||||
// It is not an error to remove a nonexistent resource.
|
||||
func (s *Server) RemoveResourceTemplates(uriTemplates ...string) {
|
||||
s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{},
|
||||
func() bool { return s.resourceTemplates.remove(uriTemplates...) })
|
||||
}
|
||||
|
||||
// changeAndNotify is called when a feature is added or removed.
|
||||
// It calls change, which should do the work and report whether a change actually occurred.
|
||||
// If there was a change, it notifies a snapshot of the sessions.
|
||||
|
|
@ -243,19 +269,32 @@ func (s *Server) listResources(_ context.Context, _ *ServerSession, params *List
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) listResourceTemplates(_ context.Context, _ *ServerSession, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if params == nil {
|
||||
params = &ListResourceTemplatesParams{}
|
||||
}
|
||||
return paginateList(s.resourceTemplates, s.opts.PageSize, params, &ListResourceTemplatesResult{},
|
||||
func(res *ListResourceTemplatesResult, rts []*ServerResourceTemplate) {
|
||||
res.ResourceTemplates = []*ResourceTemplate{} // avoid JSON null
|
||||
for _, rt := range rts {
|
||||
res.ResourceTemplates = append(res.ResourceTemplates, rt.ResourceTemplate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) readResource(ctx context.Context, ss *ServerSession, params *ReadResourceParams) (*ReadResourceResult, error) {
|
||||
uri := params.URI
|
||||
// Look up the resource URI in the list we have.
|
||||
// Look up the resource URI in the lists of resources and resource templates.
|
||||
// This is a security check as well as an information lookup.
|
||||
s.mu.Lock()
|
||||
resource, ok := s.resources.get(uri)
|
||||
s.mu.Unlock()
|
||||
handler, mimeType, ok := s.lookupResourceHandler(uri)
|
||||
if !ok {
|
||||
// Don't expose the server configuration to the client.
|
||||
// Treat an unregistered resource the same as a registered one that couldn't be found.
|
||||
return nil, ResourceNotFoundError(uri)
|
||||
}
|
||||
res, err := resource.Handler(ctx, ss, params)
|
||||
res, err := handler(ctx, ss, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -268,12 +307,30 @@ func (s *Server) readResource(ctx context.Context, ss *ServerSession, params *Re
|
|||
c.URI = uri
|
||||
}
|
||||
if c.MIMEType == "" {
|
||||
c.MIMEType = resource.Resource.MIMEType
|
||||
c.MIMEType = mimeType
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// lookupResourceHandler returns the resource handler and MIME type for the resource or
|
||||
// resource template matching uri. If none, the last return value is false.
|
||||
func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Try resources first.
|
||||
if r, ok := s.resources.get(uri); ok {
|
||||
return r.Handler, r.Resource.MIMEType, true
|
||||
}
|
||||
// Look for matching template.
|
||||
for rt := range s.resourceTemplates.all() {
|
||||
if rt.Matches(uri) {
|
||||
return rt.Handler, rt.ResourceTemplate.MIMEType, true
|
||||
}
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// fileResourceHandler returns a ReadResourceHandler that reads paths using dir as
|
||||
// a base directory.
|
||||
// It honors client roots and protects against path traversal attacks.
|
||||
|
|
@ -312,8 +369,8 @@ func fileResourceHandler(dir string) ResourceHandler {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO(jba): figure out mime type.
|
||||
return &ReadResourceResult{Contents: []*ResourceContents{NewBlobResourceContents(params.URI, "text/plain", data)}}, nil
|
||||
// TODO(jba): figure out mime type. Omit for now: Server.readResource will fill it in.
|
||||
return &ReadResourceResult{Contents: []*ResourceContents{NewBlobResourceContents(params.URI, "", data)}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,6 +512,7 @@ var serverMethodInfos = map[string]methodInfo{
|
|||
methodListTools: newMethodInfo(serverMethod((*Server).listTools)),
|
||||
methodCallTool: newMethodInfo(serverMethod((*Server).callTool)),
|
||||
methodListResources: newMethodInfo(serverMethod((*Server).listResources)),
|
||||
methodListResourceTemplates: newMethodInfo(serverMethod((*Server).listResourceTemplates)),
|
||||
methodReadResource: newMethodInfo(serverMethod((*Server).readResource)),
|
||||
methodSetLevel: newMethodInfo(sessionMethod((*ServerSession).setLevel)),
|
||||
notificationInitialized: newMethodInfo(serverMethod((*Server).callInitializedHandler)),
|
||||
|
|
|
|||
0
mcp/testdata/files/template.txt
vendored
Normal file
0
mcp/testdata/files/template.txt
vendored
Normal file
0
mcp/testdata/private.txt
vendored
Normal file
0
mcp/testdata/private.txt
vendored
Normal file
0
mcp/testdata/public/dir/file3.txt
vendored
Normal file
0
mcp/testdata/public/dir/file3.txt
vendored
Normal file
0
mcp/testdata/public/file1.txt
vendored
Normal file
0
mcp/testdata/public/file1.txt
vendored
Normal file
0
mcp/testdata/public/file2.txt
vendored
Normal file
0
mcp/testdata/public/file2.txt
vendored
Normal file
Loading…
Reference in a new issue