From 5c8ffc62ba955ea3cd7a5f0bcb849b97a3e989db Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 25 Jun 2026 11:59:32 +0200 Subject: [PATCH 1/4] mcp/experimental: add ServerCard convenience helper Adds experimental Server Card types and helpers for SEP-2127, including building a card from implementation metadata and serving it with discovery headers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mcp/experimental/servercard/doc.go | 31 ++ mcp/experimental/servercard/servercard.go | 307 ++++++++++++++++++ .../servercard/servercard_test.go | 192 +++++++++++ 3 files changed, 530 insertions(+) create mode 100644 mcp/experimental/servercard/doc.go create mode 100644 mcp/experimental/servercard/servercard.go create mode 100644 mcp/experimental/servercard/servercard_test.go diff --git a/mcp/experimental/servercard/doc.go b/mcp/experimental/servercard/doc.go new file mode 100644 index 00000000..4be83380 --- /dev/null +++ b/mcp/experimental/servercard/doc.go @@ -0,0 +1,31 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package servercard builds and serves MCP Server Cards (SEP-2127). +// +// Server Cards are static JSON documents that describe a remote MCP server's +// identity and connection details for pre-connection discovery. They are +// experimental and may change as SEP-2127 evolves. +// +// A typical server builds a card from its MCP implementation metadata and serves +// it near its Streamable HTTP endpoint: +// +// impl := &mcp.Implementation{ +// Name: "dice-roller", +// Title: "Dice Roller", +// Description: "Rolls dice for tabletop games.", +// Version: "1.0.0", +// } +// card, err := servercard.BuildServerCard(impl, +// servercard.WithName("com.example/dice-roller"), +// servercard.WithRemotes(servercard.Remote{ +// Type: servercard.RemoteTypeStreamableHTTP, +// URL: "https://dice.example.com/mcp", +// }), +// ) +// if err != nil { +// // handle error +// } +// mux.Handle("/mcp/server-card", servercard.Handler(card)) +package servercard diff --git a/mcp/experimental/servercard/servercard.go b/mcp/experimental/servercard/servercard.go new file mode 100644 index 00000000..e67b19f3 --- /dev/null +++ b/mcp/experimental/servercard/servercard.go @@ -0,0 +1,307 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package servercard + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + // MediaType is the canonical media type for MCP Server Card documents. + MediaType = "application/mcp-server-card+json" + + // SchemaURL is the canonical v1 Server Card JSON Schema URL. + SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json" + + // DefaultPath is the recommended path for serving a Server Card relative to a + // Streamable HTTP endpoint. + DefaultPath = "/server-card" + + // RemoteTypeStreamableHTTP identifies a Streamable HTTP MCP endpoint. + RemoteTypeStreamableHTTP = "streamable-http" + + // RemoteTypeSSE identifies an SSE MCP endpoint. + RemoteTypeSSE = "sse" +) + +var ( + nameRE = regexp.MustCompile(`^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$`) + remoteURLRE = regexp.MustCompile(`^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$`) + versionRangeOperatorRE = regexp.MustCompile(`[\^~|]|[<>]=?|\s`) + versionWildcardSegmentRE = regexp.MustCompile(`(?:^|\.)[xX*](?:\.|$)`) +) + +// Icon is an optionally sized icon that can be displayed in a user interface. +type Icon = mcp.Icon + +// Input describes a user-supplied or pre-set input value for remote URL +// variables and header values. +type Input struct { + Description string `json:"description,omitempty"` + IsRequired bool `json:"isRequired,omitempty"` + IsSecret bool `json:"isSecret,omitempty"` + Format string `json:"format,omitempty"` + Default string `json:"default,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Value string `json:"value,omitempty"` + Choices []string `json:"choices,omitempty"` +} + +// KeyValueInput is a named input used for HTTP headers. +type KeyValueInput struct { + Input + Name string `json:"name"` + Variables map[string]Input `json:"variables,omitempty"` +} + +// Repository describes source repository metadata for a Server Card. +type Repository struct { + URL string `json:"url"` + Source string `json:"source"` + Subfolder string `json:"subfolder,omitempty"` + ID string `json:"id,omitempty"` +} + +// Remote describes connection metadata for a remote MCP server endpoint. +type Remote struct { + Type string `json:"type"` + URL string `json:"url"` + Headers []KeyValueInput `json:"headers,omitempty"` + Variables map[string]Input `json:"variables,omitempty"` + SupportedProtocolVersions []string `json:"supportedProtocolVersions,omitempty"` +} + +// ServerCard is a static metadata document describing a remote MCP server. +type ServerCard struct { + Schema string `json:"$schema"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description"` + Version string `json:"version"` + WebsiteURL string `json:"websiteUrl,omitempty"` + Icons []Icon `json:"icons,omitempty"` + Repository *Repository `json:"repository,omitempty"` + Remotes []Remote `json:"remotes,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` +} + +type buildOptions struct { + name string + schema string + remotes []Remote + repository *Repository + meta map[string]any +} + +// BuildOption configures [BuildServerCard]. +type BuildOption func(*buildOptions) + +// WithName sets the Server Card's reverse-DNS namespace/name identifier. +func WithName(name string) BuildOption { + return func(o *buildOptions) { + o.name = name + } +} + +// WithSchema sets the Server Card schema URL. If unset, [SchemaURL] is used. +func WithSchema(schema string) BuildOption { + return func(o *buildOptions) { + o.schema = schema + } +} + +// WithRemotes sets the remote endpoints advertised by the Server Card. +func WithRemotes(remotes ...Remote) BuildOption { + return func(o *buildOptions) { + o.remotes = append([]Remote(nil), remotes...) + } +} + +// WithRepository sets repository metadata for source inspection. +func WithRepository(repository Repository) BuildOption { + return func(o *buildOptions) { + o.repository = &repository + } +} + +// WithMeta sets extension metadata for the Server Card's _meta field. +func WithMeta(meta map[string]any) BuildOption { + return func(o *buildOptions) { + o.meta = copyMap(meta) + } +} + +// BuildServerCard builds a Server Card from MCP implementation identity +// metadata. +// +// The implementation provides the title, description, version, website URL, and +// icons. The card name is supplied with [WithName] because MCP implementation +// names are free-form while Server Card names must be reverse-DNS namespace/name +// identifiers. +func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard, error) { + if impl == nil { + return nil, errors.New("implementation must not be nil") + } + cfg := buildOptions{schema: SchemaURL} + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + if cfg.name == "" { + return nil, errors.New("server card name must be set") + } + if impl.Version == "" { + return nil, errors.New("implementation version must be set to build a Server Card") + } + if impl.Description == "" { + return nil, errors.New("implementation description must be set to build a Server Card") + } + card := &ServerCard{ + Schema: cfg.schema, + Name: cfg.name, + Title: impl.Title, + Description: impl.Description, + Version: impl.Version, + WebsiteURL: impl.WebsiteURL, + Icons: append([]Icon(nil), impl.Icons...), + Repository: cfg.repository, + Remotes: append([]Remote(nil), cfg.remotes...), + Meta: copyMap(cfg.meta), + } + if err := card.Validate(); err != nil { + return nil, err + } + return card, nil +} + +// Validate reports whether c satisfies the Server Card schema constraints that +// are enforced by this package. +func (c *ServerCard) Validate() error { + if c == nil { + return errors.New("server card must not be nil") + } + if c.Schema != SchemaURL { + return fmt.Errorf("server card schema must be %q", SchemaURL) + } + if c.Name == "" { + return errors.New("server card name must be set") + } + if len(c.Name) < 3 || len(c.Name) > 200 || !nameRE.MatchString(c.Name) { + return fmt.Errorf("server card name must match reverse-DNS namespace/name format: %q", c.Name) + } + if c.Description == "" { + return errors.New("server card description must be set") + } + if len(c.Description) > 100 { + return fmt.Errorf("server card description must be at most 100 characters") + } + if c.Version == "" { + return errors.New("server card version must be set") + } + if len(c.Version) > 255 { + return fmt.Errorf("server card version must be at most 255 characters") + } + if isVersionRange(c.Version) { + return fmt.Errorf("server card version must be an exact version, not a range/wildcard: %q", c.Version) + } + if c.Title != "" && len(c.Title) > 100 { + return fmt.Errorf("server card title must be at most 100 characters") + } + for i, icon := range c.Icons { + if icon.Source == "" { + return fmt.Errorf("server card icon %d source must be set", i) + } + } + if c.Repository != nil { + if c.Repository.URL == "" { + return errors.New("server card repository URL must be set") + } + if c.Repository.Source == "" { + return errors.New("server card repository source must be set") + } + } + for i, remote := range c.Remotes { + if remote.Type != RemoteTypeStreamableHTTP && remote.Type != RemoteTypeSSE { + return fmt.Errorf("server card remote %d has unsupported type %q", i, remote.Type) + } + if remote.URL == "" { + return fmt.Errorf("server card remote %d URL must be set", i) + } + if !remoteURLRE.MatchString(remote.URL) { + return fmt.Errorf("server card remote %d URL must start with http://, https://, or a template variable", i) + } + for j, header := range remote.Headers { + if header.Name == "" { + return fmt.Errorf("server card remote %d header %d name must be set", i, j) + } + } + } + return nil +} + +// Handler returns an HTTP handler that serves card as a Server Card discovery +// document. +func Handler(card *ServerCard) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + setDiscoveryHeaders(w.Header()) + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := card.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + body, err := json.Marshal(card) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", MediaType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) +} + +// Mount registers [Handler] on mux at path. If path is empty, [DefaultPath] is +// used. +func Mount(mux *http.ServeMux, path string, card *ServerCard) { + if path == "" { + path = DefaultPath + } + mux.Handle(path, Handler(card)) +} + +func setDiscoveryHeaders(h http.Header) { + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", http.MethodGet) + h.Set("Access-Control-Allow-Headers", "Content-Type") + h.Set("Cache-Control", "public, max-age=3600") +} + +func isVersionRange(version string) bool { + release, _, _ := strings.Cut(version, "-") + return versionRangeOperatorRE.MatchString(version) || versionWildcardSegmentRE.MatchString(release) +} + +func copyMap[M ~map[string]V, V any](m M) M { + if m == nil { + return nil + } + copy := make(M, len(m)) + for k, v := range m { + copy[k] = v + } + return copy +} diff --git a/mcp/experimental/servercard/servercard_test.go b/mcp/experimental/servercard/servercard_test.go new file mode 100644 index 00000000..94a499dd --- /dev/null +++ b/mcp/experimental/servercard/servercard_test.go @@ -0,0 +1,192 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package servercard + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func testImplementation() *mcp.Implementation { + return &mcp.Implementation{ + Name: "dice-roller", + Title: "Dice Roller", + Description: "Rolls dice.", + Version: "1.0.0", + WebsiteURL: "https://example.com/dice", + Icons: []mcp.Icon{{ + Source: "https://example.com/icon.png", + MIMEType: "image/png", + Sizes: []string{"48x48"}, + }}, + } +} + +func TestBuildServerCard(t *testing.T) { + card, err := BuildServerCard(testImplementation(), + WithName("com.example/dice-roller"), + WithRemotes(Remote{Type: RemoteTypeStreamableHTTP, URL: "https://dice.example.com/mcp"}), + WithRepository(Repository{URL: "https://github.com/example/dice", Source: "github"}), + WithMeta(map[string]any{"com.example/foo": "bar"}), + ) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + if card.Schema != SchemaURL { + t.Fatalf("card.Schema = %q, want %q", card.Schema, SchemaURL) + } + if card.Name != "com.example/dice-roller" { + t.Errorf("card.Name = %q", card.Name) + } + if card.Title != "Dice Roller" || card.Description != "Rolls dice." || card.Version != "1.0.0" || card.WebsiteURL != "https://example.com/dice" { + t.Errorf("card identity = %+v", card) + } + if len(card.Remotes) != 1 || card.Remotes[0].URL != "https://dice.example.com/mcp" { + t.Fatalf("card.Remotes = %+v", card.Remotes) + } + if card.Repository == nil || card.Repository.Source != "github" { + t.Fatalf("card.Repository = %+v", card.Repository) + } + if card.Meta["com.example/foo"] != "bar" { + t.Fatalf("card.Meta = %+v", card.Meta) + } +} + +func TestBuildServerCardValidation(t *testing.T) { + tests := []struct { + name string + impl *mcp.Implementation + opts []BuildOption + want string + }{ + { + name: "nil implementation", + want: "implementation", + }, + { + name: "missing card name", + impl: testImplementation(), + want: "name", + }, + { + name: "missing version", + impl: &mcp.Implementation{Name: "x", Description: "desc"}, + opts: []BuildOption{WithName("com.example/no-version")}, + want: "version", + }, + { + name: "missing description", + impl: &mcp.Implementation{Name: "x", Version: "1.0.0"}, + opts: []BuildOption{WithName("com.example/no-description")}, + want: "description", + }, + { + name: "version range", + impl: &mcp.Implementation{Name: "x", Description: "desc", Version: ">=1.0.0"}, + opts: []BuildOption{WithName("com.example/range")}, + want: "exact version", + }, + { + name: "version wildcard", + impl: &mcp.Implementation{Name: "x", Description: "desc", Version: "1.x"}, + opts: []BuildOption{WithName("com.example/wildcard")}, + want: "exact version", + }, + { + name: "invalid name", + impl: testImplementation(), + opts: []BuildOption{WithName("no-slash")}, + want: "name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildServerCard(tt.impl, tt.opts...) + if err == nil { + t.Fatal("BuildServerCard() succeeded, want error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("BuildServerCard() error = %v, want substring %q", err, tt.want) + } + }) + } +} + +func TestExactVersionsAccepted(t *testing.T) { + for _, version := range []string{"1.0.0", "1.0.0-x", "1.0.0-X.1", "1.0.0-rc.x", "2024-01-05"} { + t.Run(version, func(t *testing.T) { + impl := testImplementation() + impl.Version = version + card, err := BuildServerCard(impl, WithName("com.example/dice")) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + if card.Version != version { + t.Fatalf("card.Version = %q, want %q", card.Version, version) + } + }) + } +} + +func TestHandlerServesCardWithDiscoveryHeaders(t *testing.T) { + card, err := BuildServerCard(testImplementation(), + WithName("com.example/dice"), + WithRemotes(Remote{Type: RemoteTypeStreamableHTTP, URL: "https://dice.example.com/mcp"}), + ) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + + r := httptest.NewRequest(http.MethodGet, "/server-card", nil) + w := httptest.NewRecorder() + Handler(card).ServeHTTP(w, r) + res := w.Result() + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK) + } + if got := res.Header.Get("Content-Type"); got != MediaType { + t.Fatalf("Content-Type = %q, want %q", got, MediaType) + } + for key, want := range map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": http.MethodGet, + "Access-Control-Allow-Headers": "Content-Type", + "Cache-Control": "public, max-age=3600", + } { + if got := res.Header.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + var got ServerCard + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatalf("decoding response: %v", err) + } + if got.Schema != SchemaURL || got.Name != card.Name || got.Remotes[0].URL != card.Remotes[0].URL { + t.Fatalf("response card = %+v, want %+v", got, card) + } +} + +func TestMountUsesDefaultPath(t *testing.T) { + card, err := BuildServerCard(testImplementation(), WithName("com.example/dice")) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + mux := http.NewServeMux() + Mount(mux, "", card) + + r := httptest.NewRequest(http.MethodGet, DefaultPath, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Result().StatusCode, http.StatusOK) + } +} From d4fcacc0714e712a1ee151ebcbe536abdeedc134 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 25 Jun 2026 12:24:25 +0200 Subject: [PATCH 2/4] mcp/experimental: add ETag support to ServerCard handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mcp/experimental/servercard/doc.go | 8 +- mcp/experimental/servercard/servercard.go | 68 +++++-- .../servercard/servercard_test.go | 186 +++++++++++++++--- 3 files changed, 219 insertions(+), 43 deletions(-) diff --git a/mcp/experimental/servercard/doc.go b/mcp/experimental/servercard/doc.go index 4be83380..7ef84cc3 100644 --- a/mcp/experimental/servercard/doc.go +++ b/mcp/experimental/servercard/doc.go @@ -12,13 +12,13 @@ // it near its Streamable HTTP endpoint: // // impl := &mcp.Implementation{ -// Name: "dice-roller", -// Title: "Dice Roller", -// Description: "Rolls dice for tabletop games.", -// Version: "1.0.0", +// Name: "dice-roller", +// Title: "Dice Roller", +// Version: "1.0.0", // } // card, err := servercard.BuildServerCard(impl, // servercard.WithName("com.example/dice-roller"), +// servercard.WithDescription("Rolls dice for tabletop games."), // servercard.WithRemotes(servercard.Remote{ // Type: servercard.RemoteTypeStreamableHTTP, // URL: "https://dice.example.com/mcp", diff --git a/mcp/experimental/servercard/servercard.go b/mcp/experimental/servercard/servercard.go index e67b19f3..74781368 100644 --- a/mcp/experimental/servercard/servercard.go +++ b/mcp/experimental/servercard/servercard.go @@ -5,6 +5,8 @@ package servercard import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -95,11 +97,12 @@ type ServerCard struct { } type buildOptions struct { - name string - schema string - remotes []Remote - repository *Repository - meta map[string]any + name string + description string + schema string + remotes []Remote + repository *Repository + meta map[string]any } // BuildOption configures [BuildServerCard]. @@ -112,6 +115,13 @@ func WithName(name string) BuildOption { } } +// WithDescription sets the Server Card's short user-facing description. +func WithDescription(description string) BuildOption { + return func(o *buildOptions) { + o.description = description + } +} + // WithSchema sets the Server Card schema URL. If unset, [SchemaURL] is used. func WithSchema(schema string) BuildOption { return func(o *buildOptions) { @@ -143,10 +153,10 @@ func WithMeta(meta map[string]any) BuildOption { // BuildServerCard builds a Server Card from MCP implementation identity // metadata. // -// The implementation provides the title, description, version, website URL, and -// icons. The card name is supplied with [WithName] because MCP implementation -// names are free-form while Server Card names must be reverse-DNS namespace/name -// identifiers. +// The implementation provides the title, version, website URL, and icons. The +// card name is supplied with [WithName] because MCP implementation names are +// free-form while Server Card names must be reverse-DNS namespace/name +// identifiers. The card description is supplied with [WithDescription]. func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard, error) { if impl == nil { return nil, errors.New("implementation must not be nil") @@ -163,14 +173,14 @@ func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard if impl.Version == "" { return nil, errors.New("implementation version must be set to build a Server Card") } - if impl.Description == "" { - return nil, errors.New("implementation description must be set to build a Server Card") + if cfg.description == "" { + return nil, errors.New("server card description must be set") } card := &ServerCard{ Schema: cfg.schema, Name: cfg.name, Title: impl.Title, - Description: impl.Description, + Description: cfg.description, Version: impl.Version, WebsiteURL: impl.WebsiteURL, Icons: append([]Icon(nil), impl.Icons...), @@ -254,8 +264,8 @@ func (c *ServerCard) Validate() error { func Handler(card *ServerCard) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { setDiscoveryHeaders(w.Header()) - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -268,9 +278,18 @@ func Handler(card *ServerCard) http.Handler { http.Error(w, err.Error(), http.StatusInternalServerError) return } + sum := sha256.Sum256(body) + etag := `"` + hex.EncodeToString(sum[:]) + `"` w.Header().Set("Content-Type", MediaType) + w.Header().Set("ETag", etag) + if ifNoneMatchMatches(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(http.StatusNotModified) + return + } w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) + if r.Method == http.MethodGet { + _, _ = w.Write(body) + } }) } @@ -290,6 +309,25 @@ func setDiscoveryHeaders(h http.Header) { h.Set("Cache-Control", "public, max-age=3600") } +func ifNoneMatchMatches(header, etag string) bool { + if header == "" { + return false + } + for _, candidate := range strings.Split(header, ",") { + candidate = strings.TrimSpace(candidate) + if candidate == "*" { + return true + } + if strings.HasPrefix(candidate, "W/") || strings.HasPrefix(candidate, "w/") { + candidate = strings.TrimSpace(candidate[2:]) + } + if candidate == etag { + return true + } + } + return false +} + func isVersionRange(version string) bool { release, _, _ := strings.Cut(version, "-") return versionRangeOperatorRE.MatchString(version) || versionWildcardSegmentRE.MatchString(release) diff --git a/mcp/experimental/servercard/servercard_test.go b/mcp/experimental/servercard/servercard_test.go index 94a499dd..393d10ae 100644 --- a/mcp/experimental/servercard/servercard_test.go +++ b/mcp/experimental/servercard/servercard_test.go @@ -5,7 +5,10 @@ package servercard import ( + "crypto/sha256" "encoding/json" + "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -16,11 +19,10 @@ import ( func testImplementation() *mcp.Implementation { return &mcp.Implementation{ - Name: "dice-roller", - Title: "Dice Roller", - Description: "Rolls dice.", - Version: "1.0.0", - WebsiteURL: "https://example.com/dice", + Name: "dice-roller", + Title: "Dice Roller", + Version: "1.0.0", + WebsiteURL: "https://example.com/dice", Icons: []mcp.Icon{{ Source: "https://example.com/icon.png", MIMEType: "image/png", @@ -32,6 +34,7 @@ func testImplementation() *mcp.Implementation { func TestBuildServerCard(t *testing.T) { card, err := BuildServerCard(testImplementation(), WithName("com.example/dice-roller"), + WithDescription("Rolls dice."), WithRemotes(Remote{Type: RemoteTypeStreamableHTTP, URL: "https://dice.example.com/mcp"}), WithRepository(Repository{URL: "https://github.com/example/dice", Source: "github"}), WithMeta(map[string]any{"com.example/foo": "bar"}), @@ -77,8 +80,8 @@ func TestBuildServerCardValidation(t *testing.T) { }, { name: "missing version", - impl: &mcp.Implementation{Name: "x", Description: "desc"}, - opts: []BuildOption{WithName("com.example/no-version")}, + impl: &mcp.Implementation{Name: "x"}, + opts: []BuildOption{WithName("com.example/no-version"), WithDescription("desc")}, want: "version", }, { @@ -89,20 +92,20 @@ func TestBuildServerCardValidation(t *testing.T) { }, { name: "version range", - impl: &mcp.Implementation{Name: "x", Description: "desc", Version: ">=1.0.0"}, - opts: []BuildOption{WithName("com.example/range")}, + impl: &mcp.Implementation{Name: "x", Version: ">=1.0.0"}, + opts: []BuildOption{WithName("com.example/range"), WithDescription("desc")}, want: "exact version", }, { name: "version wildcard", - impl: &mcp.Implementation{Name: "x", Description: "desc", Version: "1.x"}, - opts: []BuildOption{WithName("com.example/wildcard")}, + impl: &mcp.Implementation{Name: "x", Version: "1.x"}, + opts: []BuildOption{WithName("com.example/wildcard"), WithDescription("desc")}, want: "exact version", }, { name: "invalid name", impl: testImplementation(), - opts: []BuildOption{WithName("no-slash")}, + opts: []BuildOption{WithName("no-slash"), WithDescription("desc")}, want: "name", }, } @@ -124,7 +127,7 @@ func TestExactVersionsAccepted(t *testing.T) { t.Run(version, func(t *testing.T) { impl := testImplementation() impl.Version = version - card, err := BuildServerCard(impl, WithName("com.example/dice")) + card, err := BuildServerCard(impl, WithName("com.example/dice"), WithDescription("desc")) if err != nil { t.Fatalf("BuildServerCard() error = %v", err) } @@ -138,6 +141,7 @@ func TestExactVersionsAccepted(t *testing.T) { func TestHandlerServesCardWithDiscoveryHeaders(t *testing.T) { card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), + WithDescription("Rolls dice."), WithRemotes(Remote{Type: RemoteTypeStreamableHTTP, URL: "https://dice.example.com/mcp"}), ) if err != nil { @@ -156,18 +160,25 @@ func TestHandlerServesCardWithDiscoveryHeaders(t *testing.T) { if got := res.Header.Get("Content-Type"); got != MediaType { t.Fatalf("Content-Type = %q, want %q", got, MediaType) } - for key, want := range map[string]string{ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": http.MethodGet, - "Access-Control-Allow-Headers": "Content-Type", - "Cache-Control": "public, max-age=3600", - } { - if got := res.Header.Get(key); got != want { - t.Errorf("%s = %q, want %q", key, got, want) - } + assertDiscoveryHeaders(t, res.Header) + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("reading response body: %v", err) + } + etag := res.Header.Get("ETag") + if want := quotedSHA256(body); etag != want { + t.Fatalf("ETag = %q, want %q", etag, want) + } + + r = httptest.NewRequest(http.MethodGet, "/server-card", nil) + w = httptest.NewRecorder() + Handler(card).ServeHTTP(w, r) + if got := w.Result().Header.Get("ETag"); got != etag { + t.Fatalf("second ETag = %q, want stable ETag %q", got, etag) } + var got ServerCard - if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + if err := json.Unmarshal(body, &got); err != nil { t.Fatalf("decoding response: %v", err) } if got.Schema != SchemaURL || got.Name != card.Name || got.Remotes[0].URL != card.Remotes[0].URL { @@ -175,8 +186,99 @@ func TestHandlerServesCardWithDiscoveryHeaders(t *testing.T) { } } +func TestHandlerHandlesIfNoneMatch(t *testing.T) { + card, err := BuildServerCard(testImplementation(), + WithName("com.example/dice"), + WithDescription("Rolls dice."), + WithRemotes(Remote{Type: RemoteTypeStreamableHTTP, URL: "https://dice.example.com/mcp"}), + ) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + + handler := Handler(card) + status, header, body := serveServerCard(t, handler, "") + if status != http.StatusOK { + t.Fatalf("initial status = %d, want %d", status, http.StatusOK) + } + etag := header.Get("ETag") + if etag == "" { + t.Fatal("initial ETag is empty") + } + + tests := []struct { + name string + ifNoneMatch string + wantStatus int + wantBody []byte + }{ + { + name: "matching strong tag", + ifNoneMatch: etag, + wantStatus: http.StatusNotModified, + }, + { + name: "matching weak tag", + ifNoneMatch: "W/" + etag, + wantStatus: http.StatusNotModified, + }, + { + name: "non-matching tag", + ifNoneMatch: `"0000000000000000000000000000000000000000000000000000000000000000"`, + wantStatus: http.StatusOK, + wantBody: body, + }, + { + name: "wildcard", + ifNoneMatch: "*", + wantStatus: http.StatusNotModified, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, header, body := serveServerCard(t, handler, tt.ifNoneMatch) + if status != tt.wantStatus { + t.Fatalf("status = %d, want %d", status, tt.wantStatus) + } + assertDiscoveryHeaders(t, header) + if got := header.Get("ETag"); got != etag { + t.Fatalf("ETag = %q, want %q", got, etag) + } + if string(body) != string(tt.wantBody) { + t.Fatalf("body = %q, want %q", body, tt.wantBody) + } + }) + } +} + +func TestHandlerServesHeadWithETag(t *testing.T) { + card, err := BuildServerCard(testImplementation(), + WithName("com.example/dice"), + WithDescription("Rolls dice."), + WithRemotes(Remote{Type: RemoteTypeStreamableHTTP, URL: "https://dice.example.com/mcp"}), + ) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + + r := httptest.NewRequest(http.MethodHead, "/server-card", nil) + w := httptest.NewRecorder() + Handler(card).ServeHTTP(w, r) + res := w.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK) + } + if got := res.Header.Get("ETag"); got == "" { + t.Fatal("ETag is empty") + } + if body := w.Body.String(); body != "" { + t.Fatalf("body = %q, want empty", body) + } +} + func TestMountUsesDefaultPath(t *testing.T) { - card, err := BuildServerCard(testImplementation(), WithName("com.example/dice")) + card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), WithDescription("Rolls dice.")) if err != nil { t.Fatalf("BuildServerCard() error = %v", err) } @@ -190,3 +292,39 @@ func TestMountUsesDefaultPath(t *testing.T) { t.Fatalf("status = %d, want %d", w.Result().StatusCode, http.StatusOK) } } + +func serveServerCard(t *testing.T, handler http.Handler, ifNoneMatch string) (int, http.Header, []byte) { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/server-card", nil) + if ifNoneMatch != "" { + r.Header.Set("If-None-Match", ifNoneMatch) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + res := w.Result() + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("reading response body: %v", err) + } + return res.StatusCode, res.Header, body +} + +func assertDiscoveryHeaders(t *testing.T, h http.Header) { + t.Helper() + for key, want := range map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": http.MethodGet, + "Access-Control-Allow-Headers": "Content-Type", + "Cache-Control": "public, max-age=3600", + } { + if got := h.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } +} + +func quotedSHA256(body []byte) string { + sum := sha256.Sum256(body) + return `"` + fmt.Sprintf("%x", sum) + `"` +} From 8cabf4795cfab7d92f4e87c4afbfeed4bf304848 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Jul 2026 22:39:35 +0200 Subject: [PATCH 3/4] fix(servercard): align validation with the draft schema Validate nested input formats, icon themes, URIs, Unicode lengths, and exact versions. Avoid caching handler error responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38f143e1-c0ff-47a9-ae4d-fd6ea9a5b7fd --- mcp/experimental/servercard/servercard.go | 96 ++++++-- .../servercard/servercard_test.go | 209 +++++++++++++++++- 2 files changed, 286 insertions(+), 19 deletions(-) diff --git a/mcp/experimental/servercard/servercard.go b/mcp/experimental/servercard/servercard.go index 74781368..020f15d1 100644 --- a/mcp/experimental/servercard/servercard.go +++ b/mcp/experimental/servercard/servercard.go @@ -11,8 +11,11 @@ import ( "errors" "fmt" "net/http" + "net/url" "regexp" "strings" + "unicode" + "unicode/utf8" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -33,13 +36,27 @@ const ( // RemoteTypeSSE identifies an SSE MCP endpoint. RemoteTypeSSE = "sse" + + // InputFormatBoolean identifies a boolean input represented as "true" or + // "false". + InputFormatBoolean = "boolean" + + // InputFormatFilePath identifies a path on the user's filesystem. + InputFormatFilePath = "filepath" + + // InputFormatNumber identifies a number represented as a decimal string. + InputFormatNumber = "number" + + // InputFormatString identifies an arbitrary string input. + InputFormatString = "string" ) var ( nameRE = regexp.MustCompile(`^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$`) remoteURLRE = regexp.MustCompile(`^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$`) - versionRangeOperatorRE = regexp.MustCompile(`[\^~|]|[<>]=?|\s`) + versionRangeOperatorRE = regexp.MustCompile(`[\^~|]|[<>]=?`) versionWildcardSegmentRE = regexp.MustCompile(`(?:^|\.)[xX*](?:\.|$)`) + versionHyphenRangeRE = regexp.MustCompile(`^\s*[vV]?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s+-\s+[vV]?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\s*$`) ) // Icon is an optionally sized icon that can be displayed in a user interface. @@ -99,7 +116,6 @@ type ServerCard struct { type buildOptions struct { name string description string - schema string remotes []Remote repository *Repository meta map[string]any @@ -122,13 +138,6 @@ func WithDescription(description string) BuildOption { } } -// WithSchema sets the Server Card schema URL. If unset, [SchemaURL] is used. -func WithSchema(schema string) BuildOption { - return func(o *buildOptions) { - o.schema = schema - } -} - // WithRemotes sets the remote endpoints advertised by the Server Card. func WithRemotes(remotes ...Remote) BuildOption { return func(o *buildOptions) { @@ -161,7 +170,7 @@ func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard if impl == nil { return nil, errors.New("implementation must not be nil") } - cfg := buildOptions{schema: SchemaURL} + cfg := buildOptions{} for _, opt := range opts { if opt != nil { opt(&cfg) @@ -177,7 +186,7 @@ func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard return nil, errors.New("server card description must be set") } card := &ServerCard{ - Schema: cfg.schema, + Schema: SchemaURL, Name: cfg.name, Title: impl.Title, Description: cfg.description, @@ -206,36 +215,49 @@ func (c *ServerCard) Validate() error { if c.Name == "" { return errors.New("server card name must be set") } - if len(c.Name) < 3 || len(c.Name) > 200 || !nameRE.MatchString(c.Name) { + nameLength := utf8.RuneCountInString(c.Name) + if nameLength < 3 || nameLength > 200 || !nameRE.MatchString(c.Name) { return fmt.Errorf("server card name must match reverse-DNS namespace/name format: %q", c.Name) } if c.Description == "" { return errors.New("server card description must be set") } - if len(c.Description) > 100 { + if utf8.RuneCountInString(c.Description) > 100 { return fmt.Errorf("server card description must be at most 100 characters") } if c.Version == "" { return errors.New("server card version must be set") } - if len(c.Version) > 255 { + if utf8.RuneCountInString(c.Version) > 255 { return fmt.Errorf("server card version must be at most 255 characters") } if isVersionRange(c.Version) { return fmt.Errorf("server card version must be an exact version, not a range/wildcard: %q", c.Version) } - if c.Title != "" && len(c.Title) > 100 { + if c.Title != "" && utf8.RuneCountInString(c.Title) > 100 { return fmt.Errorf("server card title must be at most 100 characters") } + if c.WebsiteURL != "" && !isAbsoluteURI(c.WebsiteURL) { + return fmt.Errorf("server card website URL must be an absolute URI: %q", c.WebsiteURL) + } for i, icon := range c.Icons { if icon.Source == "" { return fmt.Errorf("server card icon %d source must be set", i) } + if !isAbsoluteURI(icon.Source) { + return fmt.Errorf("server card icon %d source must be an absolute URI: %q", i, icon.Source) + } + if icon.Theme != "" && icon.Theme != mcp.IconThemeLight && icon.Theme != mcp.IconThemeDark { + return fmt.Errorf("server card icon %d has unsupported theme %q", i, icon.Theme) + } } if c.Repository != nil { if c.Repository.URL == "" { return errors.New("server card repository URL must be set") } + if !isAbsoluteURI(c.Repository.URL) { + return fmt.Errorf("server card repository URL must be an absolute URI: %q", c.Repository.URL) + } if c.Repository.Source == "" { return errors.New("server card repository source must be set") } @@ -250,37 +272,63 @@ func (c *ServerCard) Validate() error { if !remoteURLRE.MatchString(remote.URL) { return fmt.Errorf("server card remote %d URL must start with http://, https://, or a template variable", i) } + for name, input := range remote.Variables { + if err := validateInput(input); err != nil { + return fmt.Errorf("server card remote %d variable %q: %w", i, name, err) + } + } for j, header := range remote.Headers { if header.Name == "" { return fmt.Errorf("server card remote %d header %d name must be set", i, j) } + if err := validateInput(header.Input); err != nil { + return fmt.Errorf("server card remote %d header %d: %w", i, j, err) + } + for name, input := range header.Variables { + if err := validateInput(input); err != nil { + return fmt.Errorf("server card remote %d header %d variable %q: %w", i, j, name, err) + } + } } } return nil } +func validateInput(input Input) error { + switch input.Format { + case "", InputFormatBoolean, InputFormatFilePath, InputFormatNumber, InputFormatString: + return nil + default: + return fmt.Errorf("unsupported input format %q", input.Format) + } +} + // Handler returns an HTTP handler that serves card as a Server Card discovery // document. func Handler(card *ServerCard) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { setDiscoveryHeaders(w.Header()) if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Cache-Control", "no-store") w.Header().Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := card.Validate(); err != nil { + w.Header().Set("Cache-Control", "no-store") http.Error(w, err.Error(), http.StatusInternalServerError) return } body, err := json.Marshal(card) if err != nil { + w.Header().Set("Cache-Control", "no-store") http.Error(w, err.Error(), http.StatusInternalServerError) return } sum := sha256.Sum256(body) etag := `"` + hex.EncodeToString(sum[:]) + `"` w.Header().Set("Content-Type", MediaType) + w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("ETag", etag) if ifNoneMatchMatches(r.Header.Get("If-None-Match"), etag) { w.WriteHeader(http.StatusNotModified) @@ -306,7 +354,6 @@ func setDiscoveryHeaders(h http.Header) { h.Set("Access-Control-Allow-Origin", "*") h.Set("Access-Control-Allow-Methods", http.MethodGet) h.Set("Access-Control-Allow-Headers", "Content-Type") - h.Set("Cache-Control", "public, max-age=3600") } func ifNoneMatchMatches(header, etag string) bool { @@ -329,8 +376,21 @@ func ifNoneMatchMatches(header, etag string) bool { } func isVersionRange(version string) bool { - release, _, _ := strings.Cut(version, "-") - return versionRangeOperatorRE.MatchString(version) || versionWildcardSegmentRE.MatchString(release) + withoutBuild, _, _ := strings.Cut(version, "+") + release, _, _ := strings.Cut(withoutBuild, "-") + return versionRangeOperatorRE.MatchString(version) || + versionWildcardSegmentRE.MatchString(release) || + versionHyphenRangeRE.MatchString(version) +} + +func isAbsoluteURI(value string) bool { + for _, r := range value { + if r > unicode.MaxASCII || unicode.IsSpace(r) || unicode.IsControl(r) { + return false + } + } + uri, err := url.ParseRequestURI(value) + return err == nil && uri.IsAbs() } func copyMap[M ~map[string]V, V any](m M) M { diff --git a/mcp/experimental/servercard/servercard_test.go b/mcp/experimental/servercard/servercard_test.go index 393d10ae..389c6575 100644 --- a/mcp/experimental/servercard/servercard_test.go +++ b/mcp/experimental/servercard/servercard_test.go @@ -102,12 +102,56 @@ func TestBuildServerCardValidation(t *testing.T) { opts: []BuildOption{WithName("com.example/wildcard"), WithDescription("desc")}, want: "exact version", }, + { + name: "version hyphen range", + impl: &mcp.Implementation{Name: "x", Version: "1.2.3 - 2.0.0"}, + opts: []BuildOption{WithName("com.example/hyphen-range"), WithDescription("desc")}, + want: "exact version", + }, + { + name: "version hyphen range with build metadata", + impl: &mcp.Implementation{Name: "x", Version: "1.2.3+left - 2.0.0+right"}, + opts: []BuildOption{WithName("com.example/hyphen-range-build"), WithDescription("desc")}, + want: "exact version", + }, { name: "invalid name", impl: testImplementation(), opts: []BuildOption{WithName("no-slash"), WithDescription("desc")}, want: "name", }, + { + name: "invalid website URL", + impl: &mcp.Implementation{Name: "x", Version: "1.0.0", WebsiteURL: "relative/path"}, + opts: []BuildOption{WithName("com.example/website"), WithDescription("desc")}, + want: "website URL", + }, + { + name: "website URL with unescaped space", + impl: &mcp.Implementation{Name: "x", Version: "1.0.0", WebsiteURL: "https://example.com/foo bar"}, + opts: []BuildOption{WithName("com.example/website-space"), WithDescription("desc")}, + want: "website URL", + }, + { + name: "invalid icon source", + impl: &mcp.Implementation{ + Name: "x", + Version: "1.0.0", + Icons: []mcp.Icon{{Source: "relative/path"}}, + }, + opts: []BuildOption{WithName("com.example/icon"), WithDescription("desc")}, + want: "icon", + }, + { + name: "invalid repository URL", + impl: &mcp.Implementation{Name: "x", Version: "1.0.0"}, + opts: []BuildOption{ + WithName("com.example/repository"), + WithDescription("desc"), + WithRepository(Repository{URL: "relative/path", Source: "github"}), + }, + want: "repository URL", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -123,7 +167,7 @@ func TestBuildServerCardValidation(t *testing.T) { } func TestExactVersionsAccepted(t *testing.T) { - for _, version := range []string{"1.0.0", "1.0.0-x", "1.0.0-X.1", "1.0.0-rc.x", "2024-01-05"} { + for _, version := range []string{"1.0.0", "1.0.0-x", "1.0.0-X.1", "1.0.0-rc.x", "1.0.0+build.x", "2024-01-05", "release 2026"} { t.Run(version, func(t *testing.T) { impl := testImplementation() impl.Version = version @@ -138,6 +182,129 @@ func TestExactVersionsAccepted(t *testing.T) { } } +func TestValidateCountsUnicodeCharacters(t *testing.T) { + tests := []struct { + name string + limit int + set func(*ServerCard, string) + }{ + { + name: "description", + limit: 100, + set: func(card *ServerCard, value string) { card.Description = value }, + }, + { + name: "title", + limit: 100, + set: func(card *ServerCard, value string) { card.Title = value }, + }, + { + name: "version", + limit: 255, + set: func(card *ServerCard, value string) { card.Version = value }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), WithDescription("Rolls dice.")) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + tt.set(card, strings.Repeat("界", tt.limit)) + if err := card.Validate(); err != nil { + t.Fatalf("Validate() rejected %d Unicode characters: %v", tt.limit, err) + } + tt.set(card, strings.Repeat("界", tt.limit+1)) + if err := card.Validate(); err == nil { + t.Fatalf("Validate() accepted %d Unicode characters, want error", tt.limit+1) + } + }) + } +} + +func TestValidateNestedEnums(t *testing.T) { + tests := []struct { + name string + mutate func(*ServerCard) + want string + }{ + { + name: "icon theme", + mutate: func(card *ServerCard) { + card.Icons = []Icon{{Source: "https://example.com/icon.png", Theme: mcp.IconTheme("auto")}} + }, + want: "theme", + }, + { + name: "remote variable format", + mutate: func(card *ServerCard) { + card.Remotes = []Remote{{ + Type: RemoteTypeStreamableHTTP, + URL: "https://example.com/{tenant}/mcp", + Variables: map[string]Input{"tenant": {Format: "date"}}, + }} + }, + want: "input format", + }, + { + name: "header format", + mutate: func(card *ServerCard) { + card.Remotes = []Remote{{ + Type: RemoteTypeStreamableHTTP, + URL: "https://example.com/mcp", + Headers: []KeyValueInput{{ + Name: "Authorization", + Input: Input{Format: "date"}, + }}, + }} + }, + want: "input format", + }, + { + name: "header variable format", + mutate: func(card *ServerCard) { + card.Remotes = []Remote{{ + Type: RemoteTypeStreamableHTTP, + URL: "https://example.com/mcp", + Headers: []KeyValueInput{{ + Name: "Authorization", + Input: Input{Value: "Bearer {token}"}, + Variables: map[string]Input{"token": {Format: "date"}}, + }}, + }} + }, + want: "input format", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), WithDescription("Rolls dice.")) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + tt.mutate(card) + err = card.Validate() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Validate() error = %v, want substring %q", err, tt.want) + } + }) + } +} + +func TestAbsoluteURIsAccepted(t *testing.T) { + for _, uri := range []string{ + "https://example.com/a%20b", + "data:image/png;base64,AAAA", + "urn:air:example", + } { + t.Run(uri, func(t *testing.T) { + if !isAbsoluteURI(uri) { + t.Fatalf("isAbsoluteURI(%q) = false, want true", uri) + } + }) + } +} + func TestHandlerServesCardWithDiscoveryHeaders(t *testing.T) { card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), @@ -277,6 +444,46 @@ func TestHandlerServesHeadWithETag(t *testing.T) { } } +func TestHandlerDoesNotCacheErrors(t *testing.T) { + card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), WithDescription("Rolls dice.")) + if err != nil { + t.Fatalf("BuildServerCard() error = %v", err) + } + + tests := []struct { + name string + method string + card *ServerCard + wantStatus int + }{ + { + name: "method not allowed", + method: http.MethodPost, + card: card, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "invalid card", + method: http.MethodGet, + card: &ServerCard{}, + wantStatus: http.StatusInternalServerError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(tt.method, "/server-card", nil) + w := httptest.NewRecorder() + Handler(tt.card).ServeHTTP(w, r) + if w.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", w.Code, tt.wantStatus) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want %q", got, "no-store") + } + }) + } +} + func TestMountUsesDefaultPath(t *testing.T) { card, err := BuildServerCard(testImplementation(), WithName("com.example/dice"), WithDescription("Rolls dice.")) if err != nil { From 0553d749aa6c28a9106448265aad75bbc506e454 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Jul 2026 22:39:42 +0200 Subject: [PATCH 4/4] docs(servercard): add experimental developer guide Document building, serving, and publishing Server Cards with the Go SDK, including AI Catalog context, security guidance, and checked examples. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38f143e1-c0ff-47a9-ae4d-fd6ea9a5b7fd --- docs/README.md | 4 + docs/server_cards.md | 219 ++++++++++++++++++ internal/docs/README.src.md | 4 + internal/docs/doc.go | 1 + internal/docs/server_cards.src.md | 159 +++++++++++++ mcp/experimental/servercard/doc.go | 16 +- .../servercard/servercard_example_test.go | 110 +++++++++ mkdocs.yaml | 2 + 8 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 docs/server_cards.md create mode 100644 internal/docs/server_cards.src.md create mode 100644 mcp/experimental/servercard/servercard_example_test.go diff --git a/docs/README.md b/docs/README.md index d07cfa0f..2f2776a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,10 @@ protocol. 1. [Logging](server.md#logging) 1. [Pagination](server.md#pagination) +## Experimental Extensions + +1. [Server Cards](server_cards.md) + # TroubleShooting See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. diff --git a/docs/server_cards.md b/docs/server_cards.md new file mode 100644 index 00000000..dc9334db --- /dev/null +++ b/docs/server_cards.md @@ -0,0 +1,219 @@ + +# MCP Server Cards + +1. [Discovery](#discovery) +1. [Card fields](#card-fields) +1. [Build a card](#build-a-card) +1. [Serve a card](#serve-a-card) +1. [Host static JSON](#host-static-json) +1. [AI Catalog context](#ai-catalog-context) +1. [Security and operations](#security-and-operations) + +!!! warning "Experimental API" + + Server Cards and the Go APIs in + [`mcp/experimental/servercard`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard) + are experimental. They may change or be removed without deprecation as + [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) + evolves. Pin the SDK version if you depend on them in a deployed system. + +An MCP Server Card is a static JSON document that describes a remote server's +identity and connection details before a client connects. The format is being +developed in the +[experimental Server Card extension](https://github.com/modelcontextprotocol/experimental-ext-server-card). + +A Server Card describes connectivity, not capability. It does not list tools, +resources, or prompts. Its claims are advisory: after connecting, clients must +use the MCP initialization result and the live runtime APIs as the authoritative +source for server metadata, capabilities, and access. + +## Discovery + +A typical discovery flow is: + +1. A client obtains a Server Card URL from trusted configuration or an AI + Catalog. +2. The client fetches the URL with an `Accept: + application/mcp-server-card+json` header. +3. The client validates the card and selects one of its advertised remote + endpoints. +4. The client connects and performs normal MCP initialization and capability + discovery. + +A card may be served from any unreserved URI. For a Streamable HTTP endpoint, +the recommended location is `/server-card`. For example, +an endpoint at `https://dice.example.com/mcp` would normally publish its card +at `https://dice.example.com/mcp/server-card`. + +The Go SDK currently builds, validates, and serves Server Cards. It does not +provide an AI Catalog client or automatically discover card URLs. + +## Card fields + +| JSON field | Required | Description | +| --- | --- | --- | +| `$schema` | Yes | The Server Card schema URL. [`BuildServerCard`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard#BuildServerCard) uses the canonical v1 schema. | +| `name` | Yes | A stable reverse-DNS namespace/name identifier, such as `com.example/dice-roller`. | +| `description` | Yes | A user-facing description of at most 100 characters. | +| `version` | Yes | The exact server version. Ranges and wildcards are invalid. | +| `title` | No | A human-readable display name. | +| `websiteUrl` | No | The server's website. | +| `icons` | No | Icons suitable for display in a client. | +| `repository` | No | Source repository metadata. | +| `remotes` | No | Streamable HTTP or legacy SSE connection endpoints. | +| `_meta` | No | Namespaced extension metadata. | + +The canonical v1 schema identifier currently returns `404` while the extension +is pre-release. Until it is published, use the extension repository's generated +[`schema.json`](https://github.com/modelcontextprotocol/experimental-ext-server-card/blob/3b2d974dbbc1bcf899e0ed2ef49a91758853c9a6/schema.json) +as the validation payload. Its +[`schema.ts`](https://github.com/modelcontextprotocol/experimental-ext-server-card/blob/3b2d974dbbc1bcf899e0ed2ef49a91758853c9a6/schema.ts) +source is authoritative. + +`BuildServerCard` copies `title`, `version`, `websiteUrl`, and `icons` from +[`mcp.Implementation`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Implementation). +The API accepts the card's name separately because `Implementation.Name` is +free-form, but a Server Card-enabled server should use the same stable +reverse-DNS identifier for both values so its pre-connection and runtime +identities do not contradict each other. + +## Build a card + +Use `BuildServerCard` with an exact implementation version, a reverse-DNS card +name, and a short description. Add remotes, repository information, and +namespaced metadata as needed. + +```go +func ExampleBuildServerCard() { + impl := &mcp.Implementation{ + Name: "com.example/dice-roller", + Title: "Dice Roller", + Version: "1.0.0", + WebsiteURL: "https://dice.example.com", + } + card, err := servercard.BuildServerCard(impl, + servercard.WithName("com.example/dice-roller"), + servercard.WithDescription("Rolls dice for tabletop games."), + servercard.WithRemotes(servercard.Remote{ + Type: servercard.RemoteTypeStreamableHTTP, + URL: "https://dice.example.com/mcp", + }), + servercard.WithRepository(servercard.Repository{ + URL: "https://github.com/example/dice-roller", + Source: "github", + }), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Println(card.Name, card.Version) + fmt.Println(card.Remotes[0].URL) + // Output: + // com.example/dice-roller 1.0.0 + // https://dice.example.com/mcp +} +``` + +`BuildServerCard` validates the result before returning it. Call +[`ServerCard.Validate`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard#ServerCard.Validate) +again after modifying a card directly. + +## Serve a card + +Serve the card on a public route that does not require MCP authentication. +`Mount` registers the handler on an `http.ServeMux`: + +```go +mux := http.NewServeMux() +servercard.Mount(mux, "/mcp/server-card", card) +``` + +When the MCP transport endpoint is `/mcp`, pass `/mcp/server-card` explicitly. +Calling `Mount` with an empty path uses `servercard.DefaultPath` +(`/server-card`) on the mux; it does not infer the transport endpoint. + +The handler: + +- accepts `GET` and `HEAD`; +- returns `application/mcp-server-card+json`; +- enables cross-origin reads with `Access-Control-Allow-Origin: *`; +- sends `Cache-Control: public, max-age=3600`; +- sends a strong content-derived `ETag`; and +- returns `304 Not Modified` when `If-None-Match` matches. + +ETag handling is additional Go handler behavior. The current extension does +not require `ETag` or `If-None-Match`. + +## Host static JSON + +Server Cards are static metadata, so they can also be generated during a build +and hosted by a web server, object store, or CDN: + +```go +func Example_staticFile() { + card, err := servercard.BuildServerCard( + &mcp.Implementation{Name: "com.example/dice-roller", Version: "1.0.0"}, + servercard.WithName("com.example/dice-roller"), + servercard.WithDescription("Rolls dice for tabletop games."), + ) + if err != nil { + log.Fatal(err) + } + + data, err := json.MarshalIndent(card, "", " ") + if err != nil { + log.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile("server-card.json", data, 0o644); err != nil { + log.Fatal(err) + } +} +``` + +The example uses a card returned by `BuildServerCard`, so it is already +validated. Configure the hosting layer to return `servercard.MediaType`, allow +cross-origin `GET` requests, and use appropriate cache validators. + +## AI Catalog context + +The extension's intended discovery direction is +[draft PR #42](https://github.com/modelcontextprotocol/experimental-ext-server-card/pull/42), +which delegates catalog discovery to the +[AI Catalog specification](https://github.com/Agent-Card/ai-catalog/blob/28825483143ce9f3b344ed01dc2771d4adf02d01/specification/ai-catalog.md). +AI Catalog documents use `application/ai-catalog+json`, contain `specVersion` +and `entries`, and may be published at any URL. A domain may advertise one at +`/.well-known/ai-catalog.json`. + +An MCP entry uses `type: "application/mcp-server-card+json"` and contains +exactly one of: + +- `url`, which the client fetches with `Accept: + application/mcp-server-card+json`; or +- `data`, which contains the inline Server Card. + +Use the JSON member `type`, not the obsolete `mediaType`. Avoid duplicating the +card's display name, description, version, or repository data in the catalog +entry, where those values could drift. AI Catalog defines `urn:air:` entry +identifiers and uses the `mcp` namespace for MCP entries. + +Do not publish an MCP-specific catalog at +`/.well-known/mcp/catalog.json`; that format is removed by the intended +discovery direction. The Go SDK does not currently define AI Catalog models or +derive `urn:air:` identifiers. + +## Security and operations + +Server Cards and public catalogs are normally available before authentication. +Do not include credentials, populated secret inputs, internal hostnames, or +private network topology in them. + +Treat card and AI Catalog URLs as untrusted input. A client that fetches a +discovered URL should enforce its normal outbound network policy, including +HTTPS requirements in production and protections against server-side request +forgery. Deployments should also apply reasonable rate limits, caching, and +polling intervals. + +Finally, do not use card claims as authoritative security or access-control +data. The connected MCP server's live protocol responses take precedence. diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index 3283efaf..c586de07 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -51,6 +51,10 @@ protocol. 1. [Logging](server.md#logging) 1. [Pagination](server.md#pagination) +## Experimental Extensions + +1. [Server Cards](server_cards.md) + # TroubleShooting See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. diff --git a/internal/docs/doc.go b/internal/docs/doc.go index 41640600..6ec0caa0 100644 --- a/internal/docs/doc.go +++ b/internal/docs/doc.go @@ -7,6 +7,7 @@ //go:generate weave -o ../../docs/protocol.md ./protocol.src.md //go:generate weave -o ../../docs/client.md ./client.src.md //go:generate weave -o ../../docs/server.md ./server.src.md +//go:generate weave -o ../../docs/server_cards.md ./server_cards.src.md //go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md //go:generate weave -o ../../docs/rough_edges.md ./rough_edges.src.md //go:generate weave -o ../../docs/mcpgodebug.md ./mcpgodebug.src.md diff --git a/internal/docs/server_cards.src.md b/internal/docs/server_cards.src.md new file mode 100644 index 00000000..0226d5d0 --- /dev/null +++ b/internal/docs/server_cards.src.md @@ -0,0 +1,159 @@ +# MCP Server Cards + +%toc + +!!! warning "Experimental API" + + Server Cards and the Go APIs in + [`mcp/experimental/servercard`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard) + are experimental. They may change or be removed without deprecation as + [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) + evolves. Pin the SDK version if you depend on them in a deployed system. + +An MCP Server Card is a static JSON document that describes a remote server's +identity and connection details before a client connects. The format is being +developed in the +[experimental Server Card extension](https://github.com/modelcontextprotocol/experimental-ext-server-card). + +A Server Card describes connectivity, not capability. It does not list tools, +resources, or prompts. Its claims are advisory: after connecting, clients must +use the MCP initialization result and the live runtime APIs as the authoritative +source for server metadata, capabilities, and access. + +## Discovery + +A typical discovery flow is: + +1. A client obtains a Server Card URL from trusted configuration or an AI + Catalog. +2. The client fetches the URL with an `Accept: + application/mcp-server-card+json` header. +3. The client validates the card and selects one of its advertised remote + endpoints. +4. The client connects and performs normal MCP initialization and capability + discovery. + +A card may be served from any unreserved URI. For a Streamable HTTP endpoint, +the recommended location is `/server-card`. For example, +an endpoint at `https://dice.example.com/mcp` would normally publish its card +at `https://dice.example.com/mcp/server-card`. + +The Go SDK currently builds, validates, and serves Server Cards. It does not +provide an AI Catalog client or automatically discover card URLs. + +## Card fields + +| JSON field | Required | Description | +| --- | --- | --- | +| `$schema` | Yes | The Server Card schema URL. [`BuildServerCard`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard#BuildServerCard) uses the canonical v1 schema. | +| `name` | Yes | A stable reverse-DNS namespace/name identifier, such as `com.example/dice-roller`. | +| `description` | Yes | A user-facing description of at most 100 characters. | +| `version` | Yes | The exact server version. Ranges and wildcards are invalid. | +| `title` | No | A human-readable display name. | +| `websiteUrl` | No | The server's website. | +| `icons` | No | Icons suitable for display in a client. | +| `repository` | No | Source repository metadata. | +| `remotes` | No | Streamable HTTP or legacy SSE connection endpoints. | +| `_meta` | No | Namespaced extension metadata. | + +The canonical v1 schema identifier currently returns `404` while the extension +is pre-release. Until it is published, use the extension repository's generated +[`schema.json`](https://github.com/modelcontextprotocol/experimental-ext-server-card/blob/3b2d974dbbc1bcf899e0ed2ef49a91758853c9a6/schema.json) +as the validation payload. Its +[`schema.ts`](https://github.com/modelcontextprotocol/experimental-ext-server-card/blob/3b2d974dbbc1bcf899e0ed2ef49a91758853c9a6/schema.ts) +source is authoritative. + +`BuildServerCard` copies `title`, `version`, `websiteUrl`, and `icons` from +[`mcp.Implementation`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Implementation). +The API accepts the card's name separately because `Implementation.Name` is +free-form, but a Server Card-enabled server should use the same stable +reverse-DNS identifier for both values so its pre-connection and runtime +identities do not contradict each other. + +## Build a card + +Use `BuildServerCard` with an exact implementation version, a reverse-DNS card +name, and a short description. Add remotes, repository information, and +namespaced metadata as needed. + +%include ../../mcp/experimental/servercard/servercard_example_test.go build - + +`BuildServerCard` validates the result before returning it. Call +[`ServerCard.Validate`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard#ServerCard.Validate) +again after modifying a card directly. + +## Serve a card + +Serve the card on a public route that does not require MCP authentication. +`Mount` registers the handler on an `http.ServeMux`: + +%include ../../mcp/experimental/servercard/servercard_example_test.go serve - + +When the MCP transport endpoint is `/mcp`, pass `/mcp/server-card` explicitly. +Calling `Mount` with an empty path uses `servercard.DefaultPath` +(`/server-card`) on the mux; it does not infer the transport endpoint. + +The handler: + +- accepts `GET` and `HEAD`; +- returns `application/mcp-server-card+json`; +- enables cross-origin reads with `Access-Control-Allow-Origin: *`; +- sends `Cache-Control: public, max-age=3600`; +- sends a strong content-derived `ETag`; and +- returns `304 Not Modified` when `If-None-Match` matches. + +ETag handling is additional Go handler behavior. The current extension does +not require `ETag` or `If-None-Match`. + +## Host static JSON + +Server Cards are static metadata, so they can also be generated during a build +and hosted by a web server, object store, or CDN: + +%include ../../mcp/experimental/servercard/servercard_example_test.go static - + +The example uses a card returned by `BuildServerCard`, so it is already +validated. Configure the hosting layer to return `servercard.MediaType`, allow +cross-origin `GET` requests, and use appropriate cache validators. + +## AI Catalog context + +The extension's intended discovery direction is +[draft PR #42](https://github.com/modelcontextprotocol/experimental-ext-server-card/pull/42), +which delegates catalog discovery to the +[AI Catalog specification](https://github.com/Agent-Card/ai-catalog/blob/28825483143ce9f3b344ed01dc2771d4adf02d01/specification/ai-catalog.md). +AI Catalog documents use `application/ai-catalog+json`, contain `specVersion` +and `entries`, and may be published at any URL. A domain may advertise one at +`/.well-known/ai-catalog.json`. + +An MCP entry uses `type: "application/mcp-server-card+json"` and contains +exactly one of: + +- `url`, which the client fetches with `Accept: + application/mcp-server-card+json`; or +- `data`, which contains the inline Server Card. + +Use the JSON member `type`, not the obsolete `mediaType`. Avoid duplicating the +card's display name, description, version, or repository data in the catalog +entry, where those values could drift. AI Catalog defines `urn:air:` entry +identifiers and uses the `mcp` namespace for MCP entries. + +Do not publish an MCP-specific catalog at +`/.well-known/mcp/catalog.json`; that format is removed by the intended +discovery direction. The Go SDK does not currently define AI Catalog models or +derive `urn:air:` identifiers. + +## Security and operations + +Server Cards and public catalogs are normally available before authentication. +Do not include credentials, populated secret inputs, internal hostnames, or +private network topology in them. + +Treat card and AI Catalog URLs as untrusted input. A client that fetches a +discovered URL should enforce its normal outbound network policy, including +HTTPS requirements in production and protections against server-side request +forgery. Deployments should also apply reasonable rate limits, caching, and +polling intervals. + +Finally, do not use card claims as authoritative security or access-control +data. The connected MCP server's live protocol responses take precedence. diff --git a/mcp/experimental/servercard/doc.go b/mcp/experimental/servercard/doc.go index 7ef84cc3..76df483c 100644 --- a/mcp/experimental/servercard/doc.go +++ b/mcp/experimental/servercard/doc.go @@ -2,17 +2,23 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -// Package servercard builds and serves MCP Server Cards (SEP-2127). +// Package servercard builds and serves MCP Server Cards from the experimental +// Server Card extension proposed by SEP-2127. // // Server Cards are static JSON documents that describe a remote MCP server's -// identity and connection details for pre-connection discovery. They are -// experimental and may change as SEP-2127 evolves. +// identity and connection details for pre-connection discovery. They do not +// describe the server's tools, resources, or prompts; clients must use the live +// MCP session as the authoritative source for capabilities and access. +// +// This package is experimental. Its API and the wire format may change or be +// removed without deprecation as SEP-2127 evolves. Server Cards are normally +// public, so they must not contain credentials or private network topology. // // A typical server builds a card from its MCP implementation metadata and serves // it near its Streamable HTTP endpoint: // // impl := &mcp.Implementation{ -// Name: "dice-roller", +// Name: "com.example/dice-roller", // Title: "Dice Roller", // Version: "1.0.0", // } @@ -28,4 +34,6 @@ // // handle error // } // mux.Handle("/mcp/server-card", servercard.Handler(card)) +// +// [SEP-2127]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127 package servercard diff --git a/mcp/experimental/servercard/servercard_example_test.go b/mcp/experimental/servercard/servercard_example_test.go new file mode 100644 index 00000000..f44d80e7 --- /dev/null +++ b/mcp/experimental/servercard/servercard_example_test.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package servercard_test + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp/experimental/servercard" +) + +// !+build + +func ExampleBuildServerCard() { + impl := &mcp.Implementation{ + Name: "com.example/dice-roller", + Title: "Dice Roller", + Version: "1.0.0", + WebsiteURL: "https://dice.example.com", + } + card, err := servercard.BuildServerCard(impl, + servercard.WithName("com.example/dice-roller"), + servercard.WithDescription("Rolls dice for tabletop games."), + servercard.WithRemotes(servercard.Remote{ + Type: servercard.RemoteTypeStreamableHTTP, + URL: "https://dice.example.com/mcp", + }), + servercard.WithRepository(servercard.Repository{ + URL: "https://github.com/example/dice-roller", + Source: "github", + }), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Println(card.Name, card.Version) + fmt.Println(card.Remotes[0].URL) + // Output: + // com.example/dice-roller 1.0.0 + // https://dice.example.com/mcp +} + +// !-build + +func ExampleMount() { + card, err := servercard.BuildServerCard( + &mcp.Implementation{Name: "com.example/dice-roller", Version: "1.0.0"}, + servercard.WithName("com.example/dice-roller"), + servercard.WithDescription("Rolls dice for tabletop games."), + ) + if err != nil { + log.Fatal(err) + } + + // !+serve + + mux := http.NewServeMux() + servercard.Mount(mux, "/mcp/server-card", card) + + // !-serve + + req := httptest.NewRequest(http.MethodGet, "https://dice.example.com/mcp/server-card", nil) + req.Header.Set("Accept", servercard.MediaType) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + fmt.Println(rec.Code) + fmt.Println(rec.Header().Get("Content-Type")) + fmt.Println(rec.Header().Get("Cache-Control")) + fmt.Println(rec.Header().Get("Access-Control-Allow-Origin")) + fmt.Println(rec.Header().Get("ETag") != "") + // Output: + // 200 + // application/mcp-server-card+json + // public, max-age=3600 + // * + // true +} + +// !+static + +func Example_staticFile() { + card, err := servercard.BuildServerCard( + &mcp.Implementation{Name: "com.example/dice-roller", Version: "1.0.0"}, + servercard.WithName("com.example/dice-roller"), + servercard.WithDescription("Rolls dice for tabletop games."), + ) + if err != nil { + log.Fatal(err) + } + + data, err := json.MarshalIndent(card, "", " ") + if err != nil { + log.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile("server-card.json", data, 0o644); err != nil { + log.Fatal(err) + } +} + +// !-static diff --git a/mkdocs.yaml b/mkdocs.yaml index e978c85b..6af1671c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -50,6 +50,8 @@ nav: - MCP Components: - MCP Client: client.md - MCP Server: server.md + - Experimental Extensions: + - Server Cards: server_cards.md - Troubleshooting: troubleshooting.md - Backwards Compatibility: mcpgodebug.md - Rough Edges: rough_edges.md