diff --git a/mcp/experimental/servercard/doc.go b/mcp/experimental/servercard/doc.go new file mode 100644 index 00000000..7ef84cc3 --- /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", +// 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", +// }), +// ) +// 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..74781368 --- /dev/null +++ b/mcp/experimental/servercard/servercard.go @@ -0,0 +1,345 @@ +// 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 ( + "crypto/sha256" + "encoding/hex" + "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 + description 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 + } +} + +// 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) { + 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, 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") + } + 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 cfg.description == "" { + return nil, errors.New("server card description must be set") + } + card := &ServerCard{ + Schema: cfg.schema, + Name: cfg.name, + Title: impl.Title, + Description: cfg.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 && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + 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 + } + 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) + if r.Method == http.MethodGet { + _, _ = 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 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) +} + +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..393d10ae --- /dev/null +++ b/mcp/experimental/servercard/servercard_test.go @@ -0,0 +1,330 @@ +// 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 ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "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", + 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"), + 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"}), + ) + 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"}, + opts: []BuildOption{WithName("com.example/no-version"), WithDescription("desc")}, + 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", Version: ">=1.0.0"}, + opts: []BuildOption{WithName("com.example/range"), WithDescription("desc")}, + want: "exact version", + }, + { + name: "version 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"), WithDescription("desc")}, + 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"), WithDescription("desc")) + 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"), + 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.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) + } + 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.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 { + t.Fatalf("response card = %+v, want %+v", got, card) + } +} + +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"), WithDescription("Rolls 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) + } +} + +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) + `"` +}