diff --git a/pkg/http/server.go b/pkg/http/server.go index 36d3e111b..43b041ed2 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -17,6 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/middleware" "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/http/servercard" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/observability" @@ -198,6 +199,12 @@ func RunHTTPServer(cfg ServerConfig) error { }) logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) + r.Group(func(r chi.Router) { + // Register the public, no-auth MCP Server Card (SEP-2127) endpoint. + servercard.NewHandler(servercard.Config{Version: cfg.Version}).RegisterRoutes(r) + }) + logger.Info("MCP Server Card endpoint registered", "path", servercard.Path) + addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ Addr: addr, diff --git a/pkg/http/servercard/card.go b/pkg/http/servercard/card.go new file mode 100644 index 000000000..74ce18cdc --- /dev/null +++ b/pkg/http/servercard/card.go @@ -0,0 +1,268 @@ +// Package servercard provides the GitHub MCP Server's MCP Server Card +// (SEP-2127) types and a public, no-auth HTTP handler that serves it. +// +// A Server Card is a static metadata document that describes a remote MCP +// server — its identity, repository, and HTTP transport — so clients can +// discover and connect to it before the protocol handshake. It is remote-only +// and deliberately does NOT enumerate primitives (tools, resources, prompts) +// or installable packages; those remain in the MCP Registry document +// (server.json) and runtime listing. +// +// See: +// - https://github.com/modelcontextprotocol/experimental-ext-server-card +// - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127 +package servercard + +import ( + "net/http" + + "github.com/github/github-mcp-server/pkg/octicons" +) + +const ( + // SchemaURL is the v1 Server Card JSON Schema URI that emitted cards + // conform to. The schema is versioned by its `vN` path segment. + SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json" + + // MediaType is the media type used to serve and request a Server Card. + MediaType = "application/mcp-server-card+json" + + // Path is the suffix, relative to a server's streamable-HTTP URL, at which + // MCP reserves the recommended Server Card location. A server hosted at + // `https://host/mcp` therefore serves its card at `https://host/mcp/server-card`. + Path = "/server-card" + + // DefaultRemoteURL is the streamable-HTTP endpoint of the hosted GitHub MCP + // Server on github.com. The remote repository overrides this per environment. + DefaultRemoteURL = "https://api.githubcopilot.com/mcp/" + + // iconName is the Octicon used as the server's icon (the GitHub mark). + iconName = "mark-github" + + // iconSize is the pixel dimension of the embedded Octicon PNGs (square). + iconSize = "24x24" +) + +// DefaultProtocolVersions lists the MCP protocol versions advertised on the +// card's remote when Config.ProtocolVersions is not set. It mirrors the +// versions supported by the bundled go-sdk (see modelcontextprotocol/go-sdk +// mcp.supportedProtocolVersions, which is unexported) and is ordered newest +// first. Keep it in sync when the go-sdk dependency in go.mod is upgraded. +var DefaultProtocolVersions = []string{ + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", +} + +// Identity fields reused from the MCP Registry document (server.json) so the +// Server Card and the registry entry describe the same server. +const ( + serverName = "io.github.github/github-mcp-server" + serverTitle = "GitHub" + serverDescription = "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language." + repositoryURL = "https://github.com/github/github-mcp-server" + repositorySource = "github" + // repositoryID is the github.com repository ID for github/github-mcp-server. + // It is stable across renames and changes if the repository is recreated. + repositoryID = "942771284" +) + +// ServerCard is a static metadata document describing a remote MCP server, +// suitable for pre-connection discovery. It mirrors the ServerCard interface in +// modelcontextprotocol/experimental-ext-server-card. Server Cards are +// remote-only and never carry installable packages. +type ServerCard struct { + // Schema is the Server Card JSON Schema URI this document conforms to. + Schema string `json:"$schema"` + // Name is the server name in reverse-DNS format with exactly one slash. + Name string `json:"name"` + // Version is the server version, equivalent to Implementation.version. + Version string `json:"version"` + // Description is a short, human-readable explanation of server functionality. + Description string `json:"description"` + // Title is an optional human-readable display name. + Title string `json:"title,omitempty"` + // WebsiteURL optionally links to the server's homepage or documentation. + WebsiteURL string `json:"websiteUrl,omitempty"` + // Repository optionally describes the server's source code for inspection. + Repository *Repository `json:"repository,omitempty"` + // Icons optionally lists sized icons a client may render. + Icons []Icon `json:"icons,omitempty"` + // Remotes lists the HTTP-based endpoints for connecting to the server. + Remotes []Remote `json:"remotes,omitempty"` + // Meta carries vendor-specific metadata using reverse-DNS namespacing. + Meta map[string]any `json:"_meta,omitempty"` +} + +// Repository describes the MCP server's source code location. +type Repository struct { + // URL is the repository URL for browsing source and cloning. + URL string `json:"url"` + // Source is the hosting service identifier (e.g. "github"). + Source string `json:"source"` + // Subfolder is an optional clean relative path within a monorepo. + Subfolder string `json:"subfolder,omitempty"` + // ID is the optional repository identifier owned by the hosting service. + ID string `json:"id,omitempty"` +} + +// Remote describes a remote (HTTP-based) MCP server endpoint. +type Remote struct { + // Type is the transport type ("streamable-http" or "sse"). + Type string `json:"type"` + // URL is the endpoint URL template. Variables in {curly_braces} are + // substituted from Variables before the client connects. + URL string `json:"url"` + // Headers describes HTTP headers required or accepted when connecting. + Headers []KeyValueInput `json:"headers,omitempty"` + // Variables defines values referenced by {curly_braces} in URL and headers. + Variables map[string]Input `json:"variables,omitempty"` + // SupportedProtocolVersions lists MCP protocol versions this endpoint serves. + SupportedProtocolVersions []string `json:"supportedProtocolVersions,omitempty"` +} + +// Input is a user-supplied or pre-set value for a remote URL variable or +// header value. +type Input struct { + // Description is a human-readable explanation of the input. + Description string `json:"description,omitempty"` + // IsRequired indicates the input must be supplied to connect. + IsRequired bool `json:"isRequired,omitempty"` + // IsSecret indicates the value is sensitive and must be handled securely. + IsSecret bool `json:"isSecret,omitempty"` + // Format specifies the input format ("string", "number", "boolean", "filepath"). + Format string `json:"format,omitempty"` + // Default is the default value for the input. + Default string `json:"default,omitempty"` + // Placeholder is example guidance shown during configuration. + Placeholder string `json:"placeholder,omitempty"` + // Value is a pre-set value that end users should not configure. + Value string `json:"value,omitempty"` + // Choices, when set, constrains the input to one of the listed values. + Choices []string `json:"choices,omitempty"` +} + +// KeyValueInput is a named Input used to describe an HTTP header. +type KeyValueInput struct { + Input + // Name is the header name. + Name string `json:"name"` + // Variables defines values referenced by {curly_braces} in Value. + Variables map[string]Input `json:"variables,omitempty"` +} + +// Icon is an optionally-sized icon a client may display. +type Icon struct { + // Src is a URI (HTTP(S) or data:) pointing to an icon resource. + Src string `json:"src"` + // MimeType optionally overrides the source MIME type. + MimeType string `json:"mimeType,omitempty"` + // Sizes optionally lists sizes (e.g. "48x48" or "any") the icon supports. + Sizes []string `json:"sizes,omitempty"` + // Theme optionally indicates the theme ("light" or "dark") the icon suits. + Theme string `json:"theme,omitempty"` +} + +// Config controls how the GitHub MCP Server card is built and served. +type Config struct { + // Version is advertised as the card's version and SHOULD match the + // runtime serverInfo version. When empty, "0.0.0-dev" is used. + Version string + + // RemoteURL is the absolute streamable-HTTP endpoint advertised in the + // card's single remote. When empty, DefaultRemoteURL is used. The remote + // repository supplies a per-environment URL here. + RemoteURL string + + // RemoteURLFunc, when set, derives the streamable-HTTP remote URL from the + // incoming request, taking precedence over RemoteURL whenever it returns a + // non-empty value. This supports multi-tenant deployments (e.g. proxima) + // where the absolute URL varies per request (e.g. from X-Forwarded-Host). + // + // It is consumed by the Handler when serving a card; NewServerCard ignores + // it, since the card constructor is not request-aware. + RemoteURLFunc func(*http.Request) string + + // ProtocolVersions overrides the MCP protocol versions advertised on the + // card's remote. When empty, DefaultProtocolVersions is used. + ProtocolVersions []string +} + +// NewServerCard builds the GitHub MCP Server's Server Card from cfg. +func NewServerCard(cfg Config) *ServerCard { + version := cfg.Version + if version == "" { + version = "0.0.0-dev" + } + + remoteURL := cfg.RemoteURL + if remoteURL == "" { + remoteURL = DefaultRemoteURL + } + + protocolVersions := cfg.ProtocolVersions + if protocolVersions == nil { + protocolVersions = DefaultProtocolVersions + } + + return &ServerCard{ + Schema: SchemaURL, + Name: serverName, + Version: version, + Description: serverDescription, + Title: serverTitle, + WebsiteURL: repositoryURL, + Icons: githubIcons(), + Repository: &Repository{ + URL: repositoryURL, + Source: repositorySource, + ID: repositoryID, + }, + Remotes: []Remote{ + { + Type: "streamable-http", + URL: remoteURL, + Headers: []KeyValueInput{ + { + Input: Input{ + Description: "Authorization header with authentication token (PAT or App token)", + IsRequired: true, + IsSecret: true, + }, + Name: "Authorization", + }, + }, + SupportedProtocolVersions: protocolVersions, + }, + }, + } +} + +// githubIcons returns the light- and dark-theme GitHub mark icons as +// self-contained data URIs, reusing the embedded Octicons so the card has no +// external image dependency. The order is fixed so the serialized card — and +// therefore its ETag — is deterministic. It returns nil if the icons are +// unavailable. +func githubIcons() []Icon { + themes := []struct { + octicon octicons.Theme + card string + }{ + {octicons.ThemeLight, "light"}, + {octicons.ThemeDark, "dark"}, + } + + var icons []Icon + for _, t := range themes { + if src := octicons.DataURI(iconName, t.octicon); src != "" { + icons = append(icons, Icon{ + Src: src, + MimeType: "image/png", + Sizes: []string{iconSize}, + Theme: t.card, + }) + } + } + return icons +} diff --git a/pkg/http/servercard/card_test.go b/pkg/http/servercard/card_test.go new file mode 100644 index 000000000..020b38d1e --- /dev/null +++ b/pkg/http/servercard/card_test.go @@ -0,0 +1,181 @@ +package servercard + +import ( + _ "embed" + "encoding/json" + "strings" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/server-card.schema.json +var serverCardSchema []byte + +// resolvedCardSchema parses the embedded experimental-ext-server-card schema and +// returns a resolver rooted at the ServerCard definition, so emitted cards can be +// validated against the canonical JSON Schema. +func resolvedCardSchema(t *testing.T) *jsonschema.Resolved { + t.Helper() + + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal(serverCardSchema, &schema)) + + root := &jsonschema.Schema{ + Schema: schema.Schema, + Ref: "#/$defs/ServerCard", + Defs: schema.Defs, + } + + resolved, err := root.Resolve(nil) + require.NoError(t, err) + return resolved +} + +// assertSchemaValid marshals card and validates it against the ServerCard schema. +func assertSchemaValid(t *testing.T, resolved *jsonschema.Resolved, card *ServerCard) { + t.Helper() + + raw, err := json.Marshal(card) + require.NoError(t, err) + + var instance any + require.NoError(t, json.Unmarshal(raw, &instance)) + + require.NoError(t, resolved.Validate(instance), "card must conform to the Server Card schema") +} + +func TestNewServerCard(t *testing.T) { + t.Parallel() + + resolved := resolvedCardSchema(t) + + tests := []struct { + name string + cfg Config + expectedVersion string + expectedRemoteURL string + expectedProtocolVersions []string + }{ + { + name: "defaults", + cfg: Config{}, + expectedVersion: "0.0.0-dev", + expectedRemoteURL: DefaultRemoteURL, + expectedProtocolVersions: DefaultProtocolVersions, + }, + { + name: "explicit version", + cfg: Config{Version: "1.2.3"}, + expectedVersion: "1.2.3", + expectedRemoteURL: DefaultRemoteURL, + expectedProtocolVersions: DefaultProtocolVersions, + }, + { + name: "per-environment remote URL", + cfg: Config{Version: "1.2.3", RemoteURL: "https://api.example.test/mcp/"}, + expectedVersion: "1.2.3", + expectedRemoteURL: "https://api.example.test/mcp/", + expectedProtocolVersions: DefaultProtocolVersions, + }, + { + name: "explicit protocol versions", + cfg: Config{ProtocolVersions: []string{"2025-06-18"}}, + expectedVersion: "0.0.0-dev", + expectedRemoteURL: DefaultRemoteURL, + expectedProtocolVersions: []string{"2025-06-18"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + card := NewServerCard(tc.cfg) + + assert.Equal(t, SchemaURL, card.Schema) + assert.Equal(t, "io.github.github/github-mcp-server", card.Name) + assert.Equal(t, "GitHub", card.Title) + assert.Equal(t, tc.expectedVersion, card.Version) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.WebsiteURL) + assert.LessOrEqual(t, len(card.Description), 100, "description must respect the schema maxLength") + + require.NotNil(t, card.Repository) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.Repository.URL) + assert.Equal(t, "github", card.Repository.Source) + assert.Equal(t, "942771284", card.Repository.ID) + + require.Len(t, card.Remotes, 1) + assert.Equal(t, "streamable-http", card.Remotes[0].Type) + assert.Equal(t, tc.expectedRemoteURL, card.Remotes[0].URL) + require.Len(t, card.Remotes[0].Headers, 1) + assert.Equal(t, "Authorization", card.Remotes[0].Headers[0].Name) + assert.True(t, card.Remotes[0].Headers[0].IsSecret) + assert.Equal(t, tc.expectedProtocolVersions, card.Remotes[0].SupportedProtocolVersions) + + assertSchemaValid(t, resolved, card) + }) + } +} + +// TestServerCardIcons verifies the card advertises the self-contained GitHub +// mark icons in both themes. +func TestServerCardIcons(t *testing.T) { + t.Parallel() + + card := NewServerCard(Config{}) + + require.Len(t, card.Icons, 2) + themes := make(map[string]Icon, len(card.Icons)) + for _, icon := range card.Icons { + assert.True(t, strings.HasPrefix(icon.Src, "data:image/png;base64,"), "icon must be a self-contained data URI") + assert.Equal(t, "image/png", icon.MimeType) + assert.Equal(t, []string{"24x24"}, icon.Sizes) + themes[icon.Theme] = icon + } + assert.Contains(t, themes, "light") + assert.Contains(t, themes, "dark") +} + +// TestServerCardIsDeterministic guards the ETag contract: identical Config must +// always marshal to identical bytes, so unordered sources (e.g. icons) cannot +// destabilize the response hash. +func TestServerCardIsDeterministic(t *testing.T) { + t.Parallel() + + first, err := json.Marshal(NewServerCard(Config{Version: "1.2.3"})) + require.NoError(t, err) + second, err := json.Marshal(NewServerCard(Config{Version: "1.2.3"})) + require.NoError(t, err) + assert.Equal(t, first, second) +} + +// TestServerCardIsRemoteOnly guards the SEP-2127 requirement that a Server Card +// never enumerates installable packages — those stay in the registry server.json. +func TestServerCardIsRemoteOnly(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(NewServerCard(Config{})) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + + _, hasPackages := fields["packages"] + assert.False(t, hasPackages, "Server Card must be remote-only and omit packages") + assert.Contains(t, fields, "remotes") +} + +// TestServerCardIdentityMatchesRegistry keeps the card's identity fields aligned +// with the static registry document (server.json). +func TestServerCardIdentityMatchesRegistry(t *testing.T) { + t.Parallel() + + card := NewServerCard(Config{}) + + assert.Equal(t, "io.github.github/github-mcp-server", card.Name) + assert.Equal(t, "GitHub", card.Title) + assert.True(t, strings.HasPrefix(card.Description, "Connect AI assistants to GitHub")) +} diff --git a/pkg/http/servercard/handler.go b/pkg/http/servercard/handler.go new file mode 100644 index 000000000..baad4e5e3 --- /dev/null +++ b/pkg/http/servercard/handler.go @@ -0,0 +1,181 @@ +package servercard + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/go-chi/chi/v5" +) + +// Handler serves the GitHub MCP Server's Server Card over HTTP. +// +// The card is public metadata: the handler requires no authentication, sets +// permissive CORS headers so browser-based clients can fetch it, and advises +// caching. It mirrors the structure of the OAuth protected-resource-metadata +// handler so the remote server repository can mount it identically and supply +// a per-environment remote URL via Config. +type Handler struct { + cfg Config +} + +// NewHandler returns a Handler that serves the card built from cfg. +func NewHandler(cfg Config) *Handler { + return &Handler{cfg: cfg} +} + +// RegisterRoutes mounts the Server Card handler at the single reserved +// `/server-card` location. The handler is registered for +// all methods (mirroring oauth.AuthHandler) so it owns the path and answers +// non-GET requests itself rather than falling through to the auth-gated MCP +// endpoint. +// +// The card is served at exactly one canonical location — the URL the MCP/AI +// catalog links — so it is deliberately not also exposed under any alternate +// path. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Handle(Path, h) +} + +// ServeHTTP serves the Server Card as application/mcp-server-card+json. +// +// It honors GET and HEAD (with OPTIONS preflight), performs content negotiation +// against the Accept header, supports ETag conditional requests, and is safe to +// mount at /server-card without authentication middleware. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodOptions: + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + case http.MethodGet, http.MethodHead: + // served below + default: + setCORSHeaders(w) + w.Header().Set("Allow", "GET, HEAD, OPTIONS") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if !acceptsCard(r.Header.Get(headers.AcceptHeader)) { + setCORSHeaders(w) + http.Error(w, "not acceptable: expected "+MediaType, http.StatusNotAcceptable) + return + } + + ServeCard(w, r, h.resolveCard(r)) +} + +// resolveCard builds the card for a request, applying the per-request +// RemoteURLFunc override when configured. +func (h *Handler) resolveCard(r *http.Request) *ServerCard { + cfg := h.cfg + if h.cfg.RemoteURLFunc != nil { + if url := h.cfg.RemoteURLFunc(r); url != "" { + cfg.RemoteURL = url + } + } + return NewServerCard(cfg) +} + +// ServeCard writes card to w as the canonical Server Card HTTP response and is +// the single source of truth for the response headers and conditional-request +// behavior. Callers that build a card per request — for example multi-tenant +// deployments that derive a per-request remote URL — can reuse it directly to +// guarantee byte-for-byte identical ETag and header handling. +// +// On a GET/HEAD it sets the read-only CORS headers, a one-hour Cache-Control, +// and a strong ETag (the lowercase-hex SHA-256 of the exact served body, +// double-quoted), plus Content-Type for the 200 response. When the request's +// If-None-Match matches that ETag (strong or weak form) or is `*`, it returns +// 304 Not Modified with the ETag and Cache-Control but no body. HEAD responses +// carry the same headers with an empty body. The caller is responsible for +// method dispatch and Accept negotiation before invoking ServeCard. +func ServeCard(w http.ResponseWriter, r *http.Request, card *ServerCard) { + body, err := json.Marshal(card) + if err != nil { + setCORSHeaders(w) + http.Error(w, "failed to encode server card", http.StatusInternalServerError) + return + } + + etag := computeETag(body) + + setCORSHeaders(w) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("ETag", etag) + + if ifNoneMatchSatisfied(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Header().Set(headers.ContentTypeHeader, MediaType) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(body) +} + +// computeETag returns a strong ETag: the lowercase-hex SHA-256 of body, wrapped +// in double quotes. It is deterministic for identical content. +func computeETag(body []byte) string { + sum := sha256.Sum256(body) + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// ifNoneMatchSatisfied reports whether an If-None-Match header value matches the +// given strong ETag using RFC 9110 weak comparison: `*` always matches, and a +// listed entity-tag matches if its opaque tag equals etag's, ignoring any weak +// `W/` prefix. +func ifNoneMatchSatisfied(ifNoneMatch, etag string) bool { + ifNoneMatch = strings.TrimSpace(ifNoneMatch) + if ifNoneMatch == "" { + return false + } + if ifNoneMatch == "*" { + return true + } + + target := strings.TrimPrefix(etag, "W/") + for candidate := range strings.SplitSeq(ifNoneMatch, ",") { + if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == target { + return true + } + } + return false +} + +// setCORSHeaders applies the read-only CORS policy required for discovery +// endpoints. The card contains only public metadata, so any origin may read it. +func setCORSHeaders(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") +} + +// acceptsCard reports whether an Accept header value permits the Server Card +// media type. An empty header accepts anything; otherwise the header must list +// the card media type, application/*, or */*. Parameters such as q-values and +// the media-type suffix structure are tolerated. +func acceptsCard(accept string) bool { + if strings.TrimSpace(accept) == "" { + return true + } + + for part := range strings.SplitSeq(accept, ",") { + mediaRange := strings.TrimSpace(part) + if i := strings.IndexByte(mediaRange, ';'); i >= 0 { + mediaRange = strings.TrimSpace(mediaRange[:i]) + } + switch strings.ToLower(mediaRange) { + case MediaType, "*/*", "application/*": + return true + } + } + return false +} diff --git a/pkg/http/servercard/handler_test.go b/pkg/http/servercard/handler_test.go new file mode 100644 index 000000000..96e64b03b --- /dev/null +++ b/pkg/http/servercard/handler_test.go @@ -0,0 +1,360 @@ +package servercard + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandlerServeHTTP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + accept string + expectedStatus int + expectedContentType string + expectBody bool + }{ + { + name: "GET returns the card", + method: http.MethodGet, + expectedStatus: http.StatusOK, + expectedContentType: MediaType, + expectBody: true, + }, + { + name: "GET with card media type Accept", + method: http.MethodGet, + accept: MediaType, + expectedStatus: http.StatusOK, + expectedContentType: MediaType, + expectBody: true, + }, + { + name: "GET with wildcard Accept", + method: http.MethodGet, + accept: "*/*", + expectedStatus: http.StatusOK, + expectedContentType: MediaType, + expectBody: true, + }, + { + name: "GET with Accept list including the card type", + method: http.MethodGet, + accept: "text/html, application/mcp-server-card+json;q=0.9", + expectedStatus: http.StatusOK, + expectedContentType: MediaType, + expectBody: true, + }, + { + name: "GET with incompatible Accept is rejected", + method: http.MethodGet, + accept: "text/html", + expectedStatus: http.StatusNotAcceptable, + }, + { + name: "HEAD returns headers without body", + method: http.MethodHead, + expectedStatus: http.StatusOK, + expectBody: false, + }, + { + name: "OPTIONS preflight", + method: http.MethodOptions, + expectedStatus: http.StatusOK, + }, + { + name: "POST is not allowed", + method: http.MethodPost, + expectedStatus: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(Config{Version: "1.2.3"}) + req := httptest.NewRequest(tc.method, Path, nil) + if tc.accept != "" { + req.Header.Set(headers.AcceptHeader, tc.accept) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, res.StatusCode) + + // CORS headers are always present, even on errors and preflight. + assert.Equal(t, "*", res.Header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "GET", res.Header.Get("Access-Control-Allow-Methods")) + assert.Equal(t, "Content-Type", res.Header.Get("Access-Control-Allow-Headers")) + + if tc.expectedStatus == http.StatusOK && tc.method != http.MethodOptions { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + assert.Equal(t, "public, max-age=3600", res.Header.Get("Cache-Control")) + + etag := res.Header.Get("ETag") + assert.True(t, strings.HasPrefix(etag, `"`) && strings.HasSuffix(etag, `"`), "ETag must be a quoted strong tag, got %q", etag) + assert.Len(t, etag, 66, "ETag should wrap a 64-char hex SHA-256 in quotes") + } + + if tc.expectBody { + var card ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&card)) + assert.Equal(t, SchemaURL, card.Schema) + assert.Equal(t, "1.2.3", card.Version) + require.Len(t, card.Remotes, 1) + assert.Equal(t, DefaultRemoteURL, card.Remotes[0].URL) + } + }) + } +} + +func TestHandlerRegisterRoutes(t *testing.T) { + t.Parallel() + + r := chi.NewRouter() + NewHandler(Config{}).RegisterRoutes(r) + + t.Run("GET serves the card at the canonical path", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, Path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + }) + + t.Run("POST owned by card handler", func(t *testing.T) { + t.Parallel() + + // The handler is registered for all methods so non-GET requests are + // answered here (405) rather than falling through to another route. + req := httptest.NewRequest(http.MethodPost, Path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusMethodNotAllowed, res.StatusCode) + assert.Equal(t, "*", res.Header.Get("Access-Control-Allow-Origin")) + }) + + t.Run("card is served at exactly one path", func(t *testing.T) { + t.Parallel() + + // The card must be discoverable at a single canonical location only; + // no alternate path (e.g. /mcp/server-card) is registered. + req := httptest.NewRequest(http.MethodGet, "/mcp"+Path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} + +// TestHandlerRegisterRoutesNotShadowedByCatchAll mirrors the production wiring +// (pkg/http/server.go), where the streamable MCP endpoint is mounted as a +// catch-all at "/" (pkg/http/handler.go: r.Mount("/", h)). The card's static +// route must take precedence over that wildcard mount so the card — and not the +// auth-gated MCP endpoint — answers GET and non-GET requests at the card path. +func TestHandlerRegisterRoutesNotShadowedByCatchAll(t *testing.T) { + t.Parallel() + + const mcpStatus = http.StatusUnauthorized // sentinel for the auth-gated MCP endpoint + + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mcpStatus) + })) + }) + r.Group(func(r chi.Router) { + NewHandler(Config{}).RegisterRoutes(r) + }) + + t.Run("GET is owned by the card handler", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, Path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + }) + + t.Run("non-GET is owned by the card handler, not the catch-all", func(t *testing.T) { + t.Parallel() + + // A POST must get the card handler's 405, not the MCP catch-all's + // sentinel status — proving the static route is not shadowed. + req := httptest.NewRequest(http.MethodPost, Path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusMethodNotAllowed, res.StatusCode) + assert.NotEqual(t, mcpStatus, res.StatusCode) + }) +} + +func TestHandlerETagConditionalRequests(t *testing.T) { + t.Parallel() + + handler := NewHandler(Config{Version: "1.2.3"}) + + get := func(t *testing.T, ifNoneMatch string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, Path, nil) + if ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Result() + } + + // Baseline GET yields a quoted strong ETag. + res := get(t, "") + etag := res.Header.Get("ETag") + res.Body.Close() + require.NotEmpty(t, etag) + assert.True(t, strings.HasPrefix(etag, `"`) && strings.HasSuffix(etag, `"`)) + assert.NotContains(t, etag, "W/", "served ETag must be strong") + + t.Run("ETag is stable across calls", func(t *testing.T) { + t.Parallel() + second := get(t, "") + defer second.Body.Close() + assert.Equal(t, etag, second.Header.Get("ETag")) + }) + + tests := []struct { + name string + ifNoneMatch string + expectedStatus int + expectBody bool + }{ + {name: "matching strong tag", ifNoneMatch: etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "matching weak form", ifNoneMatch: "W/" + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "wildcard", ifNoneMatch: "*", expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "within a list", ifNoneMatch: `"other", ` + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "non-matching tag", ifNoneMatch: `"deadbeef"`, expectedStatus: http.StatusOK, expectBody: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res := get(t, tc.ifNoneMatch) + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, res.StatusCode) + // ETag and Cache-Control accompany both 200 and 304 responses. + assert.Equal(t, etag, res.Header.Get("ETag")) + assert.Equal(t, "public, max-age=3600", res.Header.Get("Cache-Control")) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + if tc.expectBody { + assert.NotEmpty(t, body) + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + } else { + assert.Empty(t, body, "304 must have an empty body") + assert.Empty(t, res.Header.Get(headers.ContentTypeHeader), "304 should not carry Content-Type") + } + }) + } +} + +func TestHandlerRemoteURLFunc(t *testing.T) { + t.Parallel() + + // Simulate a multi-tenant deployment deriving the remote URL per request. + handler := NewHandler(Config{ + Version: "1.2.3", + RemoteURLFunc: func(r *http.Request) string { + return "https://" + r.Host + "/mcp/" + }, + }) + + makeReq := func(host string) *http.Response { + req := httptest.NewRequest(http.MethodGet, Path, nil) + req.Host = host + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Result() + } + + resA := makeReq("tenant-a.example.test") + var cardA ServerCard + require.NoError(t, json.NewDecoder(resA.Body).Decode(&cardA)) + etagA := resA.Header.Get("ETag") + resA.Body.Close() + + resB := makeReq("tenant-b.example.test") + var cardB ServerCard + require.NoError(t, json.NewDecoder(resB.Body).Decode(&cardB)) + etagB := resB.Header.Get("ETag") + resB.Body.Close() + + require.Len(t, cardA.Remotes, 1) + require.Len(t, cardB.Remotes, 1) + assert.Equal(t, "https://tenant-a.example.test/mcp/", cardA.Remotes[0].URL) + assert.Equal(t, "https://tenant-b.example.test/mcp/", cardB.Remotes[0].URL) + assert.NotEqual(t, etagA, etagB, "different per-tenant bodies must yield different ETags") +} + +func TestServeCardWritesCanonicalResponse(t *testing.T) { + t.Parallel() + + // ServeCard is the reusable writer remotes call with a pre-built card. + card := NewServerCard(Config{Version: "9.9.9", RemoteURL: "https://api.example.test/mcp/"}) + + req := httptest.NewRequest(http.MethodGet, Path, nil) + rec := httptest.NewRecorder() + ServeCard(rec, req, card) + + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + assert.Equal(t, "public, max-age=3600", res.Header.Get("Cache-Control")) + assert.Equal(t, "*", res.Header.Get("Access-Control-Allow-Origin")) + assert.NotEmpty(t, res.Header.Get("ETag")) + + var decoded ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&decoded)) + assert.Equal(t, "9.9.9", decoded.Version) + require.Len(t, decoded.Remotes, 1) + assert.Equal(t, "https://api.example.test/mcp/", decoded.Remotes[0].URL) +} diff --git a/pkg/http/servercard/testdata/server-card.schema.json b/pkg/http/servercard/testdata/server-card.schema.json new file mode 100644 index 000000000..4223b556b --- /dev/null +++ b/pkg/http/servercard/testdata/server-card.schema.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": ["dark", "light"], + "type": "string" + } + }, + "required": ["src"], + "type": "object" + }, + "Input": { + "description": "A user-supplied or pre-set input value, used for {@link Remote} URL\nvariables and header values.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRequired": { + "description": "Whether the input must be supplied for the connection to succeed.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + } + }, + "type": "object" + }, + "KeyValueInput": { + "description": "A named {@link Input} — used for HTTP headers — whose `value` may reference\nvariables for substitution.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRequired": { + "description": "Whether the input must be supplied for the connection to succeed.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "name": { + "description": "Name of the header.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Variables referenced by `{curly_braces}` identifiers in `value`. The map\nkey is the variable name; the value defines the variable's properties.", + "type": "object" + } + }, + "required": ["name"], + "type": "object" + }, + "MetaObject": { + "additionalProperties": {}, + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Any prefix consisting of zero or more labels, followed by `modelcontextprotocol` or `mcp`, followed by any label, is **reserved** for MCP use. For example: `modelcontextprotocol.io/`, `mcp.dev/`, `api.modelcontextprotocol.org/`, and `tools.mcp.com/` are all reserved.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "Remote": { + "description": "Metadata for connecting to a remote (HTTP-based) MCP server endpoint.", + "properties": { + "headers": { + "description": "HTTP headers required or accepted when connecting to this remote\nendpoint. Each header is described as a {@link KeyValueInput} so that\nclients can prompt users for required values, mark secrets, surface\ndefaults, and constrain to a list of choices.", + "items": { + "$ref": "#/$defs/KeyValueInput" + }, + "type": "array" + }, + "supportedProtocolVersions": { + "description": "MCP protocol versions actively supported by this remote endpoint. Allows\nclients to negotiate a compatible protocol version before initialization.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "description": "The transport type for this remote endpoint.", + "enum": ["sse", "streamable-http"], + "type": "string" + }, + "url": { + "description": "URL template for the remote endpoint. Must start with `http://`,\n`https://`, or a `{template-variable}`. Variables in `{curly_braces}`\nare substituted from the {@link Remote.variables} map before the\nclient connects.", + "pattern": "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Configuration variables that can be referenced as `{curly_braces}`\nplaceholders in `url` (and inside header values via\n{@link KeyValueInput.variables}). The map key is the variable\nname; the value defines the variable's properties (e.g., human-readable\ndescription, default, whether it is required or secret).", + "type": "object" + } + }, + "required": ["type", "url"], + "type": "object" + }, + "Repository": { + "description": "Repository metadata for the MCP server source code. Enables users and\nsecurity experts to inspect the code, improving transparency.", + "properties": { + "id": { + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID).\nOwned and determined by the source forge. Should remain stable across\nrepository renames and may be used to detect repository resurrection\nattacks — if a repository is deleted and recreated, the ID should change.", + "type": "string" + }, + "source": { + "description": "Repository hosting service identifier (e.g., `\"github\"`). Used by registries\nto determine validation and API access methods.", + "type": "string" + }, + "subfolder": { + "description": "Optional relative path from repository root to the server location within a\nmonorepo or nested package structure. Must be a clean relative path.", + "type": "string" + }, + "url": { + "description": "Repository URL for browsing source code. Should support both web browsing\nand `git clone` operations.", + "format": "uri", + "type": "string" + } + }, + "required": ["source", "url"], + "type": "object" + }, + "ServerCard": { + "description": "A static metadata document describing a remote MCP server, suitable for\npre-connection discovery. A Server Card may be hosted at any unreserved URI;\nMCP reserves `GET /server-card` as the recommended\nlocation. Clients learn a card's URL from an [AI Catalog](https://github.com/Agent-Card/ai-catalog)\nrather than guessing it.\n\nServer Cards describe only what is needed to discover and connect to a remote server:\nidentity, transport, and protocol versions.\n\nThey do not enumerate primitives (tools, resources, prompts) — those remain\nsubject to runtime listing via the protocol's standard list operations.\n\nThe fields a Server Card does declare (identity, transport, protocol\nversions) are advisory, not authoritative: they should be consistent with\nthe server's `server/discover` response, and clients must not treat them as\nauthoritative for security decisions. See \"Consistency with Runtime\nBehavior\" in docs/discovery.md for the normative requirement.", + "properties": { + "$schema": { + "description": "The Server Card JSON Schema URI that this document conforms to. Required.\n\nMust be the `/v1/` Server Card schema URL under\n`static.modelcontextprotocol.io/schemas/` (i.e.,\n`https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json`).\nSchema URLs are versioned by the `vN` segment rather than by date; a\nbreaking revision of the Server Card shape publishes a new `vN` family.", + "format": "uri", + "pattern": "^https://static\\.modelcontextprotocol\\.io/schemas/v1/server-card\\.schema\\.json$", + "type": "string" + }, + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Extension metadata using reverse-DNS namespacing for vendor-specific data.\n\nFollows the protocol's standard `_meta` definition." + }, + "description": { + "description": "Clear human-readable explanation of server functionality. Should focus on\ncapabilities, not implementation details.", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following\nMIME types: `image/png` and `image/jpeg` (safe, universal compatibility).\nClients SHOULD also support: `image/svg+xml` (scalable but requires security\nprecautions) and `image/webp` (modern, efficient format).", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash\nseparating namespace from server name.", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "remotes": { + "description": "Metadata helpful for making HTTP-based connections to this MCP server.", + "items": { + "$ref": "#/$defs/Remote" + }, + "type": "array" + }, + "repository": { + "$ref": "#/$defs/Repository", + "description": "Optional repository metadata for the MCP server source code.\nRecommended for transparency and security inspection." + }, + "title": { + "description": "Optional human-readable title or display name for the MCP server.\nMCP subregistries or clients MAY choose to use this for display purposes.", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version string for this server. SHOULD follow semantic versioning\n(e.g., '1.0.2', '2.1.0-alpha'). Equivalent of `Implementation.version`\nin the MCP specification. Non-semantic versions are allowed but may not\nsort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3',\n'>=1.2.3', '1.x', '1.*').", + "maxLength": 255, + "type": "string" + }, + "websiteUrl": { + "description": "Optional URL to the server's homepage, documentation, or project website.\nProvides a central link for users to learn more about the server.\nParticularly useful when the server has custom installation instructions\nor setup requirements.", + "format": "uri", + "type": "string" + } + }, + "required": ["$schema", "description", "name", "version"], + "type": "object" + } + } +}