diff --git a/pkg/http/servercard/card.go b/pkg/http/servercard/card.go new file mode 100644 index 0000000000..b4ef611eb5 --- /dev/null +++ b/pkg/http/servercard/card.go @@ -0,0 +1,147 @@ +// 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" + +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/" +) + +// 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 but 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"` + // Remotes lists the HTTP-based endpoints for connecting to the server. + Remotes []Remote `json:"remotes,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"` + // 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. Authentication is +// intentionally not described here: the hosted server advertises its auth +// requirements via OAuth protected-resource-metadata discovery, so duplicating +// them on the card would risk drift and cannot capture every accepted mode. +type Remote struct { + // Type is the transport type ("streamable-http" or "sse"). + Type string `json:"type"` + // URL is the endpoint URL. + URL string `json:"url"` +} + +// 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 +} + +// 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 + } + + // supportedProtocolVersions is intentionally omitted: the go-sdk does not + // export the list of versions it negotiates, so we cannot advertise it + // accurately from the runtime. Omitting it is preferable to publishing a + // hand-maintained list that could drift from what the server actually + // serves. + return &ServerCard{ + Schema: SchemaURL, + Name: serverName, + Version: version, + Description: serverDescription, + Title: serverTitle, + WebsiteURL: repositoryURL, + Repository: &Repository{ + URL: repositoryURL, + Source: repositorySource, + ID: repositoryID, + }, + Remotes: []Remote{ + {Type: "streamable-http", URL: remoteURL}, + }, + } +} diff --git a/pkg/http/servercard/card_test.go b/pkg/http/servercard/card_test.go new file mode 100644 index 0000000000..a20be9753e --- /dev/null +++ b/pkg/http/servercard/card_test.go @@ -0,0 +1,97 @@ +package servercard + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertCardContract checks the required Server Card fields defined by the +// experimental-ext-server-card v1 schema, plus the remote-only invariant. It is +// a focused stand-in for full JSON-Schema validation: the card is small and its +// shape is stable, so asserting the contract's required fields keeps the tests +// self-contained without vendoring the upstream schema. +func assertCardContract(t *testing.T, card *ServerCard) { + t.Helper() + + raw, err := json.Marshal(card) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + + // Required by the schema: $schema, name, version, description. + assert.Equal(t, SchemaURL, card.Schema) + assert.NotEmpty(t, card.Name) + assert.NotEmpty(t, card.Version) + require.NotEmpty(t, card.Description) + assert.LessOrEqual(t, len(card.Description), 100, "description must respect the schema maxLength") + for _, key := range []string{"$schema", "name", "version", "description"} { + assert.Contains(t, fields, key, "required field %q must be serialized", key) + } + + // Remote-only: a Server Card never enumerates installable packages — those + // stay in the registry server.json. + assert.NotContains(t, fields, "packages", "Server Card must be remote-only and omit packages") + require.Len(t, card.Remotes, 1) + assert.Equal(t, "streamable-http", card.Remotes[0].Type) +} + +func TestNewServerCard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + expectedVersion string + expectedRemoteURL string + }{ + { + name: "defaults", + cfg: Config{}, + expectedVersion: "0.0.0-dev", + expectedRemoteURL: DefaultRemoteURL, + }, + { + name: "explicit version", + cfg: Config{Version: "1.2.3"}, + expectedVersion: "1.2.3", + expectedRemoteURL: DefaultRemoteURL, + }, + { + 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/", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + card := NewServerCard(tc.cfg) + + // Identity is reused from the registry document (server.json): it is + // the stable Server Card / registry server name. The AI Catalog + // identifier is assigned independently and is not derived from it. + 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")) + assert.Equal(t, tc.expectedVersion, card.Version) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.WebsiteURL) + + 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) + + assert.Equal(t, tc.expectedRemoteURL, card.Remotes[0].URL) + + assertCardContract(t, card) + }) + } +} diff --git a/pkg/http/servercard/handler.go b/pkg/http/servercard/handler.go new file mode 100644 index 0000000000..b12ffa7c73 --- /dev/null +++ b/pkg/http/servercard/handler.go @@ -0,0 +1,217 @@ +package servercard + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "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 + caching headers required by the +// experimental-ext-server-card discovery spec. The card is public metadata, so +// any origin may read it; If-None-Match is allowed, ETag exposed, and Vary: +// Accept marks the negotiated body. Applied uniformly to every response path. +func setCORSHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET") + h.Set("Access-Control-Allow-Headers", "Content-Type, If-None-Match") + h.Set("Access-Control-Expose-Headers", "ETag") + h.Set("Vary", "Accept") +} + +// acceptsCard reports whether an Accept header value permits the Server Card +// media type, honoring RFC 9110 quality values. An empty header accepts +// anything. Otherwise the most specific matching media range decides the +// result — the exact card type over application/* over */* — and an explicit +// q=0 on that range rejects the representation (yielding 406). +func acceptsCard(accept string) bool { + if strings.TrimSpace(accept) == "" { + return true + } + + // Among the media ranges that match the card, RFC 9110 gives precedence to + // the most specific: exact type (2) over application/* (1) over */* (0). The + // quality of that most specific match decides acceptability; q=0 rejects. + bestSpecificity := -1 + var bestQuality float64 + for part := range strings.SplitSeq(accept, ",") { + mediaRange, quality := parseMediaRange(part) + specificity := -1 + switch mediaRange { + case MediaType: + specificity = 2 + case "application/*": + specificity = 1 + case "*/*": + specificity = 0 + } + if specificity > bestSpecificity { + bestSpecificity = specificity + bestQuality = quality + } + } + return bestSpecificity >= 0 && bestQuality > 0 +} + +// parseMediaRange splits one Accept media range into its lowercased media type +// and quality value. The quality defaults to 1.0 when no valid q parameter is +// present; only the q parameter is interpreted and other parameters are ignored. +func parseMediaRange(part string) (mediaType string, quality float64) { + segments := strings.Split(part, ";") + mediaType = strings.ToLower(strings.TrimSpace(segments[0])) + quality = 1.0 + for _, param := range segments[1:] { + param = strings.ToLower(strings.TrimSpace(param)) + if value, ok := strings.CutPrefix(param, "q="); ok { + if q, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil { + quality = q + } + } + } + return mediaType, quality +} diff --git a/pkg/http/servercard/handler_test.go b/pkg/http/servercard/handler_test.go new file mode 100644 index 0000000000..44e2d25a85 --- /dev/null +++ b/pkg/http/servercard/handler_test.go @@ -0,0 +1,319 @@ +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" +) + +// assertCanonicalCORS asserts the CORS and cache-negotiation headers mandated by +// the experimental-ext-server-card discovery spec. They are applied uniformly to +// every card response, including errors, preflight, and 304s. +func assertCanonicalCORS(t *testing.T, res *http.Response) { + t.Helper() + h := res.Header + assert.Equal(t, "*", h.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "GET", h.Get("Access-Control-Allow-Methods")) + assert.Equal(t, "Content-Type, If-None-Match", h.Get("Access-Control-Allow-Headers")) + assert.Equal(t, "ETag", h.Get("Access-Control-Expose-Headers")) + assert.Equal(t, "Accept", h.Get("Vary")) +} + +// assertStrongETag asserts the response carries a quoted, strong SHA-256 ETag +// and returns it. The tag wraps a 64-char hex digest in double quotes. +func assertStrongETag(t *testing.T, res *http.Response) string { + t.Helper() + 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") + return etag +} + +func TestHandlerServeHTTP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + accept string + expectedStatus int + expectBody bool + }{ + { + name: "GET returns the card", + method: http.MethodGet, + expectedStatus: http.StatusOK, + expectBody: true, + }, + { + name: "GET with wildcard Accept", + method: http.MethodGet, + accept: "*/*", + expectedStatus: http.StatusOK, + 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, + expectBody: true, + }, + { + name: "GET with incompatible Accept is rejected", + method: http.MethodGet, + accept: "text/html", + expectedStatus: http.StatusNotAcceptable, + }, + { + name: "GET with card type explicitly refused (q=0)", + method: http.MethodGet, + accept: "application/mcp-server-card+json;q=0", + expectedStatus: http.StatusNotAcceptable, + }, + { + name: "GET with wildcard refused (q=0)", + method: http.MethodGet, + accept: "*/*;q=0", + expectedStatus: http.StatusNotAcceptable, + }, + { + name: "GET with application wildcard refused (q=0)", + method: http.MethodGet, + accept: "application/*;q=0", + 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. + assertCanonicalCORS(t, res) + + if tc.expectedStatus == http.StatusOK && tc.method != http.MethodOptions { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + assertStrongETag(t, res) + } + + 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() + + // Mirror production wiring (pkg/http/server.go): the streamable MCP endpoint + // is a catch-all mount at "/" (pkg/http/handler.go: r.Mount("/", h)). The + // card's static route must take precedence over that wildcard so the card — + // not the auth-gated MCP endpoint — answers every request at its single + // canonical path, and no alternate path (e.g. /mcp/server-card) exists. + const mcpStatus = http.StatusUnauthorized // sentinel for the auth-gated MCP catch-all + + 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) + }) + + tests := []struct { + name string + method string + path string + wantStatus int + wantCard bool + }{ + {name: "canonical GET is served by the card, not the catch-all", method: http.MethodGet, path: Path, wantStatus: http.StatusOK, wantCard: true}, + {name: "non-GET at the card path is the card's 405, not the catch-all", method: http.MethodPost, path: Path, wantStatus: http.StatusMethodNotAllowed}, + {name: "no alternate path is registered", method: http.MethodGet, path: "/mcp" + Path, wantStatus: mcpStatus}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.wantStatus, res.StatusCode) + if tc.wantCard { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + } + }) + } +} + +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 := assertStrongETag(t, res) + res.Body.Close() + + 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/" + }, + }) + + fetch := func(host string) (string, string) { + req := httptest.NewRequest(http.MethodGet, Path, nil) + req.Host = host + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + var card ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&card)) + require.Len(t, card.Remotes, 1) + return card.Remotes[0].URL, res.Header.Get("ETag") + } + + urlA, etagA := fetch("tenant-a.example.test") + urlB, etagB := fetch("tenant-b.example.test") + + assert.Equal(t, "https://tenant-a.example.test/mcp/", urlA) + assert.Equal(t, "https://tenant-b.example.test/mcp/", urlB) + 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)) + assertCanonicalCORS(t, res) + assert.NotEmpty(t, res.Header.Get("ETag")) + + var decoded ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&decoded)) + require.Len(t, decoded.Remotes, 1) + assert.Equal(t, "https://api.example.test/mcp/", decoded.Remotes[0].URL) +}