Skip to content

Commit 5c8ffc6

Browse files
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>
1 parent 827f90b commit 5c8ffc6

3 files changed

Lines changed: 530 additions & 0 deletions

File tree

mcp/experimental/servercard/doc.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package servercard builds and serves MCP Server Cards (SEP-2127).
6+
//
7+
// Server Cards are static JSON documents that describe a remote MCP server's
8+
// identity and connection details for pre-connection discovery. They are
9+
// experimental and may change as SEP-2127 evolves.
10+
//
11+
// A typical server builds a card from its MCP implementation metadata and serves
12+
// it near its Streamable HTTP endpoint:
13+
//
14+
// impl := &mcp.Implementation{
15+
// Name: "dice-roller",
16+
// Title: "Dice Roller",
17+
// Description: "Rolls dice for tabletop games.",
18+
// Version: "1.0.0",
19+
// }
20+
// card, err := servercard.BuildServerCard(impl,
21+
// servercard.WithName("com.example/dice-roller"),
22+
// servercard.WithRemotes(servercard.Remote{
23+
// Type: servercard.RemoteTypeStreamableHTTP,
24+
// URL: "https://dice.example.com/mcp",
25+
// }),
26+
// )
27+
// if err != nil {
28+
// // handle error
29+
// }
30+
// mux.Handle("/mcp/server-card", servercard.Handler(card))
31+
package servercard
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package servercard
6+
7+
import (
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"net/http"
12+
"regexp"
13+
"strings"
14+
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
)
17+
18+
const (
19+
// MediaType is the canonical media type for MCP Server Card documents.
20+
MediaType = "application/mcp-server-card+json"
21+
22+
// SchemaURL is the canonical v1 Server Card JSON Schema URL.
23+
SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json"
24+
25+
// DefaultPath is the recommended path for serving a Server Card relative to a
26+
// Streamable HTTP endpoint.
27+
DefaultPath = "/server-card"
28+
29+
// RemoteTypeStreamableHTTP identifies a Streamable HTTP MCP endpoint.
30+
RemoteTypeStreamableHTTP = "streamable-http"
31+
32+
// RemoteTypeSSE identifies an SSE MCP endpoint.
33+
RemoteTypeSSE = "sse"
34+
)
35+
36+
var (
37+
nameRE = regexp.MustCompile(`^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$`)
38+
remoteURLRE = regexp.MustCompile(`^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$`)
39+
versionRangeOperatorRE = regexp.MustCompile(`[\^~|]|[<>]=?|\s`)
40+
versionWildcardSegmentRE = regexp.MustCompile(`(?:^|\.)[xX*](?:\.|$)`)
41+
)
42+
43+
// Icon is an optionally sized icon that can be displayed in a user interface.
44+
type Icon = mcp.Icon
45+
46+
// Input describes a user-supplied or pre-set input value for remote URL
47+
// variables and header values.
48+
type Input struct {
49+
Description string `json:"description,omitempty"`
50+
IsRequired bool `json:"isRequired,omitempty"`
51+
IsSecret bool `json:"isSecret,omitempty"`
52+
Format string `json:"format,omitempty"`
53+
Default string `json:"default,omitempty"`
54+
Placeholder string `json:"placeholder,omitempty"`
55+
Value string `json:"value,omitempty"`
56+
Choices []string `json:"choices,omitempty"`
57+
}
58+
59+
// KeyValueInput is a named input used for HTTP headers.
60+
type KeyValueInput struct {
61+
Input
62+
Name string `json:"name"`
63+
Variables map[string]Input `json:"variables,omitempty"`
64+
}
65+
66+
// Repository describes source repository metadata for a Server Card.
67+
type Repository struct {
68+
URL string `json:"url"`
69+
Source string `json:"source"`
70+
Subfolder string `json:"subfolder,omitempty"`
71+
ID string `json:"id,omitempty"`
72+
}
73+
74+
// Remote describes connection metadata for a remote MCP server endpoint.
75+
type Remote struct {
76+
Type string `json:"type"`
77+
URL string `json:"url"`
78+
Headers []KeyValueInput `json:"headers,omitempty"`
79+
Variables map[string]Input `json:"variables,omitempty"`
80+
SupportedProtocolVersions []string `json:"supportedProtocolVersions,omitempty"`
81+
}
82+
83+
// ServerCard is a static metadata document describing a remote MCP server.
84+
type ServerCard struct {
85+
Schema string `json:"$schema"`
86+
Name string `json:"name"`
87+
Title string `json:"title,omitempty"`
88+
Description string `json:"description"`
89+
Version string `json:"version"`
90+
WebsiteURL string `json:"websiteUrl,omitempty"`
91+
Icons []Icon `json:"icons,omitempty"`
92+
Repository *Repository `json:"repository,omitempty"`
93+
Remotes []Remote `json:"remotes,omitempty"`
94+
Meta map[string]any `json:"_meta,omitempty"`
95+
}
96+
97+
type buildOptions struct {
98+
name string
99+
schema string
100+
remotes []Remote
101+
repository *Repository
102+
meta map[string]any
103+
}
104+
105+
// BuildOption configures [BuildServerCard].
106+
type BuildOption func(*buildOptions)
107+
108+
// WithName sets the Server Card's reverse-DNS namespace/name identifier.
109+
func WithName(name string) BuildOption {
110+
return func(o *buildOptions) {
111+
o.name = name
112+
}
113+
}
114+
115+
// WithSchema sets the Server Card schema URL. If unset, [SchemaURL] is used.
116+
func WithSchema(schema string) BuildOption {
117+
return func(o *buildOptions) {
118+
o.schema = schema
119+
}
120+
}
121+
122+
// WithRemotes sets the remote endpoints advertised by the Server Card.
123+
func WithRemotes(remotes ...Remote) BuildOption {
124+
return func(o *buildOptions) {
125+
o.remotes = append([]Remote(nil), remotes...)
126+
}
127+
}
128+
129+
// WithRepository sets repository metadata for source inspection.
130+
func WithRepository(repository Repository) BuildOption {
131+
return func(o *buildOptions) {
132+
o.repository = &repository
133+
}
134+
}
135+
136+
// WithMeta sets extension metadata for the Server Card's _meta field.
137+
func WithMeta(meta map[string]any) BuildOption {
138+
return func(o *buildOptions) {
139+
o.meta = copyMap(meta)
140+
}
141+
}
142+
143+
// BuildServerCard builds a Server Card from MCP implementation identity
144+
// metadata.
145+
//
146+
// The implementation provides the title, description, version, website URL, and
147+
// icons. The card name is supplied with [WithName] because MCP implementation
148+
// names are free-form while Server Card names must be reverse-DNS namespace/name
149+
// identifiers.
150+
func BuildServerCard(impl *mcp.Implementation, opts ...BuildOption) (*ServerCard, error) {
151+
if impl == nil {
152+
return nil, errors.New("implementation must not be nil")
153+
}
154+
cfg := buildOptions{schema: SchemaURL}
155+
for _, opt := range opts {
156+
if opt != nil {
157+
opt(&cfg)
158+
}
159+
}
160+
if cfg.name == "" {
161+
return nil, errors.New("server card name must be set")
162+
}
163+
if impl.Version == "" {
164+
return nil, errors.New("implementation version must be set to build a Server Card")
165+
}
166+
if impl.Description == "" {
167+
return nil, errors.New("implementation description must be set to build a Server Card")
168+
}
169+
card := &ServerCard{
170+
Schema: cfg.schema,
171+
Name: cfg.name,
172+
Title: impl.Title,
173+
Description: impl.Description,
174+
Version: impl.Version,
175+
WebsiteURL: impl.WebsiteURL,
176+
Icons: append([]Icon(nil), impl.Icons...),
177+
Repository: cfg.repository,
178+
Remotes: append([]Remote(nil), cfg.remotes...),
179+
Meta: copyMap(cfg.meta),
180+
}
181+
if err := card.Validate(); err != nil {
182+
return nil, err
183+
}
184+
return card, nil
185+
}
186+
187+
// Validate reports whether c satisfies the Server Card schema constraints that
188+
// are enforced by this package.
189+
func (c *ServerCard) Validate() error {
190+
if c == nil {
191+
return errors.New("server card must not be nil")
192+
}
193+
if c.Schema != SchemaURL {
194+
return fmt.Errorf("server card schema must be %q", SchemaURL)
195+
}
196+
if c.Name == "" {
197+
return errors.New("server card name must be set")
198+
}
199+
if len(c.Name) < 3 || len(c.Name) > 200 || !nameRE.MatchString(c.Name) {
200+
return fmt.Errorf("server card name must match reverse-DNS namespace/name format: %q", c.Name)
201+
}
202+
if c.Description == "" {
203+
return errors.New("server card description must be set")
204+
}
205+
if len(c.Description) > 100 {
206+
return fmt.Errorf("server card description must be at most 100 characters")
207+
}
208+
if c.Version == "" {
209+
return errors.New("server card version must be set")
210+
}
211+
if len(c.Version) > 255 {
212+
return fmt.Errorf("server card version must be at most 255 characters")
213+
}
214+
if isVersionRange(c.Version) {
215+
return fmt.Errorf("server card version must be an exact version, not a range/wildcard: %q", c.Version)
216+
}
217+
if c.Title != "" && len(c.Title) > 100 {
218+
return fmt.Errorf("server card title must be at most 100 characters")
219+
}
220+
for i, icon := range c.Icons {
221+
if icon.Source == "" {
222+
return fmt.Errorf("server card icon %d source must be set", i)
223+
}
224+
}
225+
if c.Repository != nil {
226+
if c.Repository.URL == "" {
227+
return errors.New("server card repository URL must be set")
228+
}
229+
if c.Repository.Source == "" {
230+
return errors.New("server card repository source must be set")
231+
}
232+
}
233+
for i, remote := range c.Remotes {
234+
if remote.Type != RemoteTypeStreamableHTTP && remote.Type != RemoteTypeSSE {
235+
return fmt.Errorf("server card remote %d has unsupported type %q", i, remote.Type)
236+
}
237+
if remote.URL == "" {
238+
return fmt.Errorf("server card remote %d URL must be set", i)
239+
}
240+
if !remoteURLRE.MatchString(remote.URL) {
241+
return fmt.Errorf("server card remote %d URL must start with http://, https://, or a template variable", i)
242+
}
243+
for j, header := range remote.Headers {
244+
if header.Name == "" {
245+
return fmt.Errorf("server card remote %d header %d name must be set", i, j)
246+
}
247+
}
248+
}
249+
return nil
250+
}
251+
252+
// Handler returns an HTTP handler that serves card as a Server Card discovery
253+
// document.
254+
func Handler(card *ServerCard) http.Handler {
255+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
256+
setDiscoveryHeaders(w.Header())
257+
if r.Method != http.MethodGet {
258+
w.Header().Set("Allow", http.MethodGet)
259+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
260+
return
261+
}
262+
if err := card.Validate(); err != nil {
263+
http.Error(w, err.Error(), http.StatusInternalServerError)
264+
return
265+
}
266+
body, err := json.Marshal(card)
267+
if err != nil {
268+
http.Error(w, err.Error(), http.StatusInternalServerError)
269+
return
270+
}
271+
w.Header().Set("Content-Type", MediaType)
272+
w.WriteHeader(http.StatusOK)
273+
_, _ = w.Write(body)
274+
})
275+
}
276+
277+
// Mount registers [Handler] on mux at path. If path is empty, [DefaultPath] is
278+
// used.
279+
func Mount(mux *http.ServeMux, path string, card *ServerCard) {
280+
if path == "" {
281+
path = DefaultPath
282+
}
283+
mux.Handle(path, Handler(card))
284+
}
285+
286+
func setDiscoveryHeaders(h http.Header) {
287+
h.Set("Access-Control-Allow-Origin", "*")
288+
h.Set("Access-Control-Allow-Methods", http.MethodGet)
289+
h.Set("Access-Control-Allow-Headers", "Content-Type")
290+
h.Set("Cache-Control", "public, max-age=3600")
291+
}
292+
293+
func isVersionRange(version string) bool {
294+
release, _, _ := strings.Cut(version, "-")
295+
return versionRangeOperatorRE.MatchString(version) || versionWildcardSegmentRE.MatchString(release)
296+
}
297+
298+
func copyMap[M ~map[string]V, V any](m M) M {
299+
if m == nil {
300+
return nil
301+
}
302+
copy := make(M, len(m))
303+
for k, v := range m {
304+
copy[k] = v
305+
}
306+
return copy
307+
}

0 commit comments

Comments
 (0)