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.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_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/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 { 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