diff --git a/mcp/client.go b/mcp/client.go index ec21b69..daebfa8 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -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) diff --git a/mcp/generate.go b/mcp/generate.go index dbe89eb..d91cf0b 100644 --- a/mcp/generate.go +++ b/mcp/generate.go @@ -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": { diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 1231d00..074e241 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -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) + } } } } diff --git a/mcp/protocol.go b/mcp/protocol.go index 1ad8ed5..05c7007 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -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 diff --git a/mcp/resource.go b/mcp/resource.go index 1fd11ab..18e0bec 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -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()) +} diff --git a/mcp/resource_test.go b/mcp/resource_test.go index 010570a..cb5e4fb 100644 --- a/mcp/resource_test.go +++ b/mcp/resource_test.go @@ -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) + } + } +} diff --git a/mcp/server.go b/mcp/server.go index ee86c49..351143b 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -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)), diff --git a/mcp/testdata/files/template.txt b/mcp/testdata/files/template.txt new file mode 100644 index 0000000..e69de29 diff --git a/mcp/testdata/private.txt b/mcp/testdata/private.txt new file mode 100644 index 0000000..e69de29 diff --git a/mcp/testdata/public/dir/file3.txt b/mcp/testdata/public/dir/file3.txt new file mode 100644 index 0000000..e69de29 diff --git a/mcp/testdata/public/file1.txt b/mcp/testdata/public/file1.txt new file mode 100644 index 0000000..e69de29 diff --git a/mcp/testdata/public/file2.txt b/mcp/testdata/public/file2.txt new file mode 100644 index 0000000..e69de29