Skip to content

Commit 8a9bccd

Browse files
jbagopherbot
authored andcommitted
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>
1 parent c53c576 commit 8a9bccd

File tree

12 files changed

+262
-25
lines changed

12 files changed

+262
-25
lines changed

internal/mcp/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@ func (cs *ClientSession) ListResources(ctx context.Context, params *ListResource
328328
return handleSend[*ListResourcesResult](ctx, cs, methodListResources, params)
329329
}
330330

331+
// ListResourceTemplates lists the resource templates that are currently available on the server.
332+
func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) {
333+
return handleSend[*ListResourceTemplatesResult](ctx, cs, methodListResourceTemplates, params)
334+
}
335+
331336
// ReadResource ask the server to read a resource and return its contents.
332337
func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) {
333338
return handleSend[*ReadResourceResult](ctx, cs, methodReadResource, params)

internal/mcp/generate.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ var declarations = config{
104104
Fields: config{"Params": {Name: "ListResourcesParams"}},
105105
},
106106
"ListResourcesResult": {},
107+
"ListResourceTemplatesRequest": {
108+
Name: "-",
109+
Fields: config{"Params": {Name: "ListResourceTemplatesParams"}},
110+
},
111+
"ListResourceTemplatesResult": {},
107112
"ListRootsRequest": {
108113
Name: "-",
109114
Fields: config{"Params": {Name: "ListRootsParams"}},
@@ -152,6 +157,11 @@ var declarations = config{
152157
Name: "-",
153158
Fields: config{"Params": {Name: "ResourceListChangedParams"}},
154159
},
160+
"ResourceTemplate": {},
161+
"ResourceTemplateListChangedNotification": {
162+
Name: "-",
163+
Fields: config{"Params": {Name: "ResourceTemplateListChangedParams"}},
164+
},
155165
"Role": {},
156166
"Root": {},
157167
"RootsListChangedNotification": {

internal/mcp/mcp_test.go

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -237,32 +237,53 @@ func TestEndToEnd(t *testing.T) {
237237
t.Errorf("resources/list mismatch (-want, +got):\n%s", diff)
238238
}
239239

240+
template := &ResourceTemplate{
241+
Name: "rt",
242+
MIMEType: "text/template",
243+
URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/'
244+
}
245+
st := &ServerResourceTemplate{ResourceTemplate: template, Handler: readHandler}
246+
s.AddResourceTemplates(st)
247+
tres, err := cs.ListResourceTemplates(ctx, nil)
248+
if err != nil {
249+
t.Fatal(err)
250+
}
251+
if diff := cmp.Diff([]*ResourceTemplate{template}, tres.ResourceTemplates); diff != "" {
252+
t.Errorf("resources/list mismatch (-want, +got):\n%s", diff)
253+
}
254+
240255
for _, tt := range []struct {
241256
uri string
242257
mimeType string // "": not found; "text/plain": resource; "text/template": template
258+
fail bool // non-nil error returned
243259
}{
244-
{"file:///info.txt", "text/plain"},
245-
{"file:///fail.txt", ""},
246-
// TODO(jba): add resource template cases when we implement them
260+
{"file:///info.txt", "text/plain", false},
261+
{"file:///fail.txt", "", false},
262+
{"file:///template.txt", "text/template", false},
263+
{"file:///../private.txt", "", true}, // not found: escaping disallowed
247264
} {
248265
rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri})
249266
if err != nil {
250267
if code := errorCode(err); code == CodeResourceNotFound {
251268
if tt.mimeType != "" {
252269
t.Errorf("%s: not found but expected it to be", tt.uri)
253270
}
254-
} else {
255-
t.Errorf("reading %s: %v", tt.uri, err)
271+
} else if !tt.fail {
272+
t.Errorf("%s: unexpected error %v", tt.uri, err)
256273
}
257-
} else if g, w := len(rres.Contents), 1; g != w {
258-
t.Errorf("got %d contents, wanted %d", g, w)
259274
} else {
260-
c := rres.Contents[0]
261-
if got := c.URI; got != tt.uri {
262-
t.Errorf("got uri %q, want %q", got, tt.uri)
263-
}
264-
if got := c.MIMEType; got != tt.mimeType {
265-
t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType)
275+
if tt.fail {
276+
t.Errorf("%s: unexpected success", tt.uri)
277+
} else if g, w := len(rres.Contents), 1; g != w {
278+
t.Errorf("got %d contents, wanted %d", g, w)
279+
} else {
280+
c := rres.Contents[0]
281+
if got := c.URI; got != tt.uri {
282+
t.Errorf("got uri %q, want %q", got, tt.uri)
283+
}
284+
if got := c.MIMEType; got != tt.mimeType {
285+
t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType)
286+
}
266287
}
267288
}
268289
}

internal/mcp/protocol.go

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/mcp/resource.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/url"
1414
"os"
1515
"path/filepath"
16+
"regexp"
1617
"strings"
1718

1819
jsonrpc2 "golang.org/x/tools/internal/jsonrpc2_v2"
@@ -25,7 +26,14 @@ type ServerResource struct {
2526
Handler ResourceHandler
2627
}
2728

29+
// A ServerResourceTemplate associates a ResourceTemplate with its handler.
30+
type ServerResourceTemplate struct {
31+
ResourceTemplate *ResourceTemplate
32+
Handler ResourceHandler
33+
}
34+
2835
// A ResourceHandler is a function that reads a resource.
36+
// It will be called when the client calls [ClientSession.ReadResource].
2937
// If it cannot find the resource, it should return the result of calling [ResourceNotFoundError].
3038
type ResourceHandler func(context.Context, *ServerSession, *ReadResourceParams) (*ReadResourceResult, error)
3139

@@ -145,3 +153,69 @@ func fileRoot(root *Root) (_ string, err error) {
145153
}
146154
return fileRoot, nil
147155
}
156+
157+
// Matches reports whether the receiver's uri template matches the uri.
158+
// TODO: use "github.com/yosida95/uritemplate/v3"
159+
func (sr *ServerResourceTemplate) Matches(uri string) bool {
160+
re, err := uriTemplateToRegexp(sr.ResourceTemplate.URITemplate)
161+
if err != nil {
162+
return false
163+
}
164+
return re.MatchString(uri)
165+
}
166+
167+
func uriTemplateToRegexp(uriTemplate string) (*regexp.Regexp, error) {
168+
pat := uriTemplate
169+
var b strings.Builder
170+
b.WriteByte('^')
171+
seen := map[string]bool{}
172+
for len(pat) > 0 {
173+
literal, rest, ok := strings.Cut(pat, "{")
174+
b.WriteString(regexp.QuoteMeta(literal))
175+
if !ok {
176+
break
177+
}
178+
expr, rest, ok := strings.Cut(rest, "}")
179+
if !ok {
180+
return nil, errors.New("missing '}'")
181+
}
182+
pat = rest
183+
if strings.ContainsRune(expr, ',') {
184+
return nil, errors.New("can't handle commas in expressions")
185+
}
186+
if strings.ContainsRune(expr, ':') {
187+
return nil, errors.New("can't handle prefix modifiers in expressions")
188+
}
189+
if len(expr) > 0 && expr[len(expr)-1] == '*' {
190+
return nil, errors.New("can't handle explode modifiers in expressions")
191+
}
192+
193+
// These sets of valid characters aren't accurate.
194+
// See https://datatracker.ietf.org/doc/html/rfc6570.
195+
var re, name string
196+
first := byte(0)
197+
if len(expr) > 0 {
198+
first = expr[0]
199+
}
200+
switch first {
201+
default:
202+
// {var} doesn't match slashes. (It should also fail to match other characters,
203+
// but this simplified implementation doesn't handle that.)
204+
re = `[^/]*`
205+
name = expr
206+
case '+':
207+
// {+var} matches anything, even slashes
208+
re = `.*`
209+
name = expr[1:]
210+
case '#', '.', '/', ';', '?', '&':
211+
return nil, fmt.Errorf("prefix character %c unsupported", first)
212+
}
213+
if seen[name] {
214+
return nil, fmt.Errorf("can't handle duplicate name %q", name)
215+
}
216+
seen[name] = true
217+
b.WriteString(re)
218+
}
219+
b.WriteByte('$')
220+
return regexp.Compile(b.String())
221+
}

internal/mcp/resource_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,25 @@ func TestReadFileResource(t *testing.T) {
112112
t.Errorf("got %q, want %q", g, want)
113113
}
114114
}
115+
116+
func TestTemplateMatch(t *testing.T) {
117+
uri := "file:///path/to/file"
118+
for _, tt := range []struct {
119+
template string
120+
want bool
121+
}{
122+
{"file:///{}/{a}/{b}", true},
123+
{"file:///{a}/{b}", false},
124+
{"file:///{+path}", true},
125+
{"file:///{a}/{+path}", true},
126+
} {
127+
re, err := uriTemplateToRegexp(tt.template)
128+
if err != nil {
129+
t.Fatalf("%s: %v", tt.template, err)
130+
}
131+
got := re.MatchString(uri)
132+
if got != tt.want {
133+
t.Errorf("%s: got %t, want %t", tt.template, got, tt.want)
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)