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:
Jonathan Amsterdam 2025-05-10 09:03:39 -04:00 • committed by Gopher Robot
commit c1524fe934

View file

@ -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())
}