Skip to content

Commit d4fcacc

Browse files
mcp/experimental: add ETag support to ServerCard handler
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5c8ffc6 commit d4fcacc

3 files changed

Lines changed: 219 additions & 43 deletions

File tree

mcp/experimental/servercard/doc.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
// it near its Streamable HTTP endpoint:
1313
//
1414
// impl := &mcp.Implementation{
15-
// Name: "dice-roller",
16-
// Title: "Dice Roller",
17-
// Description: "Rolls dice for tabletop games.",
18-
// Version: "1.0.0",
15+
// Name: "dice-roller",
16+
// Title: "Dice Roller",
17+
// Version: "1.0.0",
1918
// }
2019
// card, err := servercard.BuildServerCard(impl,
2120
// servercard.WithName("com.example/dice-roller"),
21+
// servercard.WithDescription("Rolls dice for tabletop games."),
2222
// servercard.WithRemotes(servercard.Remote{
2323
// Type: servercard.RemoteTypeStreamableHTTP,
2424
// URL: "https://dice.example.com/mcp",

mcp/experimental/servercard/servercard.go

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package servercard
66

77
import (
8+
"crypto/sha256"
9+
"encoding/hex"
810
"encoding/json"
911
"errors"
1012
"fmt"
@@ -95,11 +97,12 @@ type ServerCard struct {
9597
}
9698

9799
type buildOptions struct {
98-
name string
99-
schema string
100-
remotes []Remote
101-
repository *Repository
102-
meta map[string]any
100+
name string
101+
description string
102+
schema string
103+
remotes []Remote
104+
repository *Repository
105+
meta map[string]any
103106
}
104107

105108
// BuildOption configures [BuildServerCard].
@@ -112,6 +115,13 @@ func WithName(name string) BuildOption {
112115
}
113116
}
114117

118+
// WithDescription sets the Server Card's short user-facing description.
119+
func WithDescription(description string) BuildOption {
120+
return func(o *buildOptions) {
121+
o.description = description
122+
}
123+
}
124+
115125
// WithSchema sets the Server Card schema URL. If unset, [SchemaURL] is used.
116126
func WithSchema(schema string) BuildOption {
117127
return func(o *buildOptions) {
@@ -143,10 +153,10 @@ func WithMeta(meta map[string]any) BuildOption {
143153
// BuildServerCard builds a Server Card from MCP implementation identity
144154
// metadata.
145155
//
146-
// The implementation provides the title, description, version, website URL, and
147-
// icons. The card name is supplied with [WithName] because MCP implementation
148-
// names are free-form while Server Card names must be reverse-DNS namespace/name
149-
// identifiers.
156+
// The implementation provides the title, version, website URL, and icons. The
157+
// card name is supplied with [WithName] because MCP implementation names are
158+
// free-form while Server Card names must be reverse-DNS namespace/name
159+
// identifiers. The card description is supplied with [WithDescription].
150160
func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard, error) {
151161
if impl == nil {
152162
return nil, errors.New("implementation must not be nil")
@@ -163,14 +173,14 @@ func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard
163173
if impl.Version == "" {
164174
return nil, errors.New("implementation version must be set to build a Server Card")
165175
}
166-
if impl.Description == "" {
167-
return nil, errors.New("implementation description must be set to build a Server Card")
176+
if cfg.description == "" {
177+
return nil, errors.New("server card description must be set")
168178
}
169179
card := &ServerCard{
170180
Schema: cfg.schema,
171181
Name: cfg.name,
172182
Title: impl.Title,
173-
Description: impl.Description,
183+
Description: cfg.description,
174184
Version: impl.Version,
175185
WebsiteURL: impl.WebsiteURL,
176186
Icons: append([]Icon(nil), impl.Icons...),
@@ -254,8 +264,8 @@ func (c *ServerCard) Validate() error {
254264
func Handler(card *ServerCard) http.Handler {
255265
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
256266
setDiscoveryHeaders(w.Header())
257-
if r.Method != http.MethodGet {
258-
w.Header().Set("Allow", http.MethodGet)
267+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
268+
w.Header().Set("Allow", "GET, HEAD")
259269
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
260270
return
261271
}
@@ -268,9 +278,18 @@ func Handler(card *ServerCard) http.Handler {
268278
http.Error(w, err.Error(), http.StatusInternalServerError)
269279
return
270280
}
281+
sum := sha256.Sum256(body)
282+
etag := `"` + hex.EncodeToString(sum[:]) + `"`
271283
w.Header().Set("Content-Type", MediaType)
284+
w.Header().Set("ETag", etag)
285+
if ifNoneMatchMatches(r.Header.Get("If-None-Match"), etag) {
286+
w.WriteHeader(http.StatusNotModified)
287+
return
288+
}
272289
w.WriteHeader(http.StatusOK)
273-
_, _ = w.Write(body)
290+
if r.Method == http.MethodGet {
291+
_, _ = w.Write(body)
292+
}
274293
})
275294
}
276295

@@ -290,6 +309,25 @@ func setDiscoveryHeaders(h http.Header) {
290309
h.Set("Cache-Control", "public, max-age=3600")
291310
}
292311

312+
func ifNoneMatchMatches(header, etag string) bool {
313+
if header == "" {
314+
return false
315+
}
316+
for _, candidate := range strings.Split(header, ",") {
317+
candidate = strings.TrimSpace(candidate)
318+
if candidate == "*" {
319+
return true
320+
}
321+
if strings.HasPrefix(candidate, "W/") || strings.HasPrefix(candidate, "w/") {
322+
candidate = strings.TrimSpace(candidate[2:])
323+
}
324+
if candidate == etag {
325+
return true
326+
}
327+
}
328+
return false
329+
}
330+
293331
func isVersionRange(version string) bool {
294332
release, _, _ := strings.Cut(version, "-")
295333
return versionRangeOperatorRE.MatchString(version) || versionWildcardSegmentRE.MatchString(release)

0 commit comments

Comments
 (0)