Skip to content

Commit eaa898f

Browse files
Add MCP Server Card (SEP-2127) types + handler
Introduce pkg/http/servercard: Go types for an MCP Server Card matching the current v1 schema in modelcontextprotocol/experimental-ext-server-card, a constructor for the GitHub MCP Server's card, and a public, no-auth HTTP handler that serves it at the reserved /server-card location. The card is remote-only: it advertises identity (name, title, description, version), repository, icons, websiteUrl, and a single streamable-http remote, and deliberately omits tools/resources/prompts and installable packages. Card identity fields are reused from the registry server.json so both documents describe the same server. Serving behavior follows the discovery spec: media type application/mcp-server-card+json, Accept negotiation, the mandated four CORS headers (Allow-Origin *, Allow-Methods GET, Allow-Headers Content-Type, If-None-Match, Expose-Headers ETag), Cache-Control public max-age=3600, and a strong SHA-256 ETag with If-None-Match/304 conditional handling. To support the multi-tenant hosted deployment, the handler exposes a request-aware ServeCard helper and a Config.RemoteURLFunc hook so the remote repository can derive a per-request remote URL while reusing identical ETag/header logic. The card route is wired outside the shared MCP CORS middleware so its preflight returns the card's CORS set, while unmatched paths still receive MCP CORS. supportedProtocolVersions is intentionally omitted because the go-sdk does not export the versions it negotiates, so it cannot be advertised accurately from the runtime. Refs github/copilot-mcp-core#1855, epic github/copilot-mcp-core#1853 Spec: modelcontextprotocol/experimental-ext-server-card Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4a9f522f-6942-4b77-98a4-b2d42f19625d
1 parent 822c877 commit eaa898f

6 files changed

Lines changed: 936 additions & 6 deletions

File tree

pkg/http/server.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/github/github-mcp-server/pkg/github"
1919
"github.com/github/github-mcp-server/pkg/http/middleware"
2020
"github.com/github/github-mcp-server/pkg/http/oauth"
21+
"github.com/github/github-mcp-server/pkg/http/servercard"
2122
"github.com/github/github-mcp-server/pkg/inventory"
2223
"github.com/github/github-mcp-server/pkg/lockdown"
2324
"github.com/github/github-mcp-server/pkg/observability"
@@ -218,9 +219,14 @@ func RunHTTPServer(cfg ServerConfig) error {
218219
handler.RegisterRoutes(r)
219220
},
220221
oauthHandler.RegisterRoutes,
222+
// The Server Card is public, no-auth metadata that defines its own
223+
// complete CORS contract, so it is registered outside the shared MCP
224+
// CORS middleware (see newHTTPRouter).
225+
servercard.NewHandler(servercard.Config{Version: cfg.Version}).RegisterRoutes,
221226
)
222227
logger.Info("MCP endpoints registered", "baseURL", cfg.BaseURL)
223228
logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
229+
logger.Info("MCP Server Card endpoint registered", "path", servercard.Path)
224230

225231
addr := resolveListenAddress(cfg.ListenHost, cfg.Port)
226232
httpSvr := http.Server{
@@ -253,12 +259,23 @@ func RunHTTPServer(cfg ServerConfig) error {
253259
return nil
254260
}
255261

256-
func newHTTPRouter(registerMCPRoutes, registerOAuthRoutes func(chi.Router)) chi.Router {
257-
r := chi.NewRouter()
258-
r.Use(middleware.SetCorsHeaders)
259-
r.Group(registerMCPRoutes)
260-
r.Group(registerOAuthRoutes)
261-
return r
262+
func newHTTPRouter(registerMCPRoutes, registerOAuthRoutes, registerCardRoutes func(chi.Router)) chi.Router {
263+
// MCP and OAuth routes share the MCP CORS middleware, which also decorates
264+
// unmatched paths (404s) so browser clients always receive CORS headers.
265+
inner := chi.NewRouter()
266+
inner.Use(middleware.SetCorsHeaders)
267+
inner.Group(registerMCPRoutes)
268+
inner.Group(registerOAuthRoutes)
269+
270+
// The Server Card owns its own CORS contract (If-None-Match preflight,
271+
// Expose-Headers: ETag), so it is registered on the bare root router,
272+
// outside the shared MCP CORS middleware which short-circuits OPTIONS with a
273+
// card-incompatible header set. Every other path falls through to the inner
274+
// router; chi static-route precedence keeps /server-card ahead of it.
275+
root := chi.NewRouter()
276+
root.Group(registerCardRoutes)
277+
root.Mount("/", inner)
278+
return root
262279
}
263280

264281
func newOAuthConfig(cfg ServerConfig) *oauth.Config {

pkg/http/server_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/github/github-mcp-server/pkg/github"
1616
"github.com/github/github-mcp-server/pkg/http/middleware"
1717
"github.com/github/github-mcp-server/pkg/http/oauth"
18+
"github.com/github/github-mcp-server/pkg/http/servercard"
1819
"github.com/github/github-mcp-server/pkg/inventory"
1920
"github.com/github/github-mcp-server/pkg/utils"
2021
"github.com/go-chi/chi/v5"
@@ -103,6 +104,7 @@ func TestHTTPRouterCORSContract(t *testing.T) {
103104
http.Error(w, "metadata unavailable", http.StatusInternalServerError)
104105
})
105106
},
107+
func(chi.Router) {},
106108
)
107109

108110
tests := []struct {
@@ -190,6 +192,50 @@ func TestHTTPRouterCORSContract(t *testing.T) {
190192
}
191193
}
192194

195+
// TestHTTPRouterServerCardCORSIsolation verifies that the Server Card endpoint
196+
// is registered outside the shared MCP CORS middleware, so its OPTIONS preflight
197+
// returns the card's own CORS contract (which allows If-None-Match and exposes
198+
// ETag) rather than the MCP header set that short-circuits OPTIONS.
199+
func TestHTTPRouterServerCardCORSIsolation(t *testing.T) {
200+
router := newHTTPRouter(
201+
func(r chi.Router) {
202+
r.Post("/", func(w http.ResponseWriter, _ *http.Request) {
203+
w.WriteHeader(http.StatusNoContent)
204+
})
205+
},
206+
func(chi.Router) {},
207+
servercard.NewHandler(servercard.Config{Version: "test"}).RegisterRoutes,
208+
)
209+
210+
// The card's OPTIONS preflight must expose only the card contract.
211+
req := httptest.NewRequest(http.MethodOptions, servercard.Path, nil)
212+
req.Header.Set("Origin", "https://confer.to")
213+
rec := httptest.NewRecorder()
214+
router.ServeHTTP(rec, req)
215+
216+
assert.Equal(t, http.StatusOK, rec.Code)
217+
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"))
218+
assert.Equal(t, "GET", rec.Header().Get("Access-Control-Allow-Methods"))
219+
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "If-None-Match")
220+
assert.Equal(t, "ETag", rec.Header().Get("Access-Control-Expose-Headers"))
221+
assert.NotContains(t, rec.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id")
222+
223+
// A GET on the card still resolves to the card (not shadowed by the MCP
224+
// catch-all) and carries the ETag.
225+
req = httptest.NewRequest(http.MethodGet, servercard.Path, nil)
226+
rec = httptest.NewRecorder()
227+
router.ServeHTTP(rec, req)
228+
assert.Equal(t, http.StatusOK, rec.Code)
229+
assert.NotEmpty(t, rec.Header().Get("ETag"))
230+
231+
// The MCP route keeps its own CORS contract that exposes Mcp-Session-Id.
232+
req = httptest.NewRequest(http.MethodOptions, "/", nil)
233+
req.Header.Set("Origin", "https://confer.to")
234+
rec = httptest.NewRecorder()
235+
router.ServeHTTP(rec, req)
236+
assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id")
237+
}
238+
193239
func TestOAuthChallengeMetadataRouteContracts(t *testing.T) {
194240
const baseURL = "https://mcp.example.com"
195241
oauthCfg := &oauth.Config{
@@ -222,6 +268,7 @@ func TestOAuthChallengeMetadataRouteContracts(t *testing.T) {
222268
}
223269
},
224270
oauthHandler.RegisterRoutes,
271+
func(chi.Router) {},
225272
)
226273

227274
for _, path := range resourcePaths {

pkg/http/servercard/card.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Package servercard provides the GitHub MCP Server's MCP Server Card
2+
// (SEP-2127) types and a public, no-auth HTTP handler that serves it.
3+
//
4+
// A Server Card is a static metadata document that describes a remote MCP
5+
// server — its identity, repository, and HTTP transport — so clients can
6+
// discover and connect to it before the protocol handshake. It is remote-only
7+
// and deliberately does NOT enumerate primitives (tools, resources, prompts)
8+
// or installable packages; those remain in the MCP Registry document
9+
// (server.json) and runtime listing.
10+
//
11+
// See:
12+
// - https://github.com/modelcontextprotocol/experimental-ext-server-card
13+
// - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127
14+
package servercard
15+
16+
import (
17+
"net/http"
18+
19+
"github.com/github/github-mcp-server/pkg/octicons"
20+
)
21+
22+
const (
23+
// SchemaURL is the v1 Server Card JSON Schema URI that emitted cards
24+
// conform to. The schema is versioned by its `vN` path segment.
25+
SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json"
26+
27+
// MediaType is the media type used to serve and request a Server Card.
28+
MediaType = "application/mcp-server-card+json"
29+
30+
// Path is the suffix, relative to a server's streamable-HTTP URL, at which
31+
// MCP reserves the recommended Server Card location. A server hosted at
32+
// `https://host/mcp` therefore serves its card at `https://host/mcp/server-card`.
33+
Path = "/server-card"
34+
35+
// DefaultRemoteURL is the streamable-HTTP endpoint of the hosted GitHub MCP
36+
// Server on github.com. The remote repository overrides this per environment.
37+
DefaultRemoteURL = "https://api.githubcopilot.com/mcp/"
38+
39+
// iconName is the Octicon used as the server's icon (the GitHub mark).
40+
iconName = "mark-github"
41+
42+
// iconSize is the pixel dimension of the embedded Octicon PNGs (square).
43+
iconSize = "24x24"
44+
)
45+
46+
// Identity fields reused from the MCP Registry document (server.json) so the
47+
// Server Card and the registry entry describe the same server.
48+
const (
49+
serverName = "io.github.github/github-mcp-server"
50+
serverTitle = "GitHub"
51+
serverDescription = "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language."
52+
repositoryURL = "https://github.com/github/github-mcp-server"
53+
repositorySource = "github"
54+
// repositoryID is the github.com repository ID for github/github-mcp-server.
55+
// It is stable across renames and changes if the repository is recreated.
56+
repositoryID = "942771284"
57+
)
58+
59+
// ServerCard is a static metadata document describing a remote MCP server,
60+
// suitable for pre-connection discovery. It mirrors the ServerCard interface in
61+
// modelcontextprotocol/experimental-ext-server-card. Server Cards are
62+
// remote-only and never carry installable packages.
63+
type ServerCard struct {
64+
// Schema is the Server Card JSON Schema URI this document conforms to.
65+
Schema string `json:"$schema"`
66+
// Name is the server name in reverse-DNS format with exactly one slash.
67+
Name string `json:"name"`
68+
// Version is the server version, equivalent to Implementation.version.
69+
Version string `json:"version"`
70+
// Description is a short, human-readable explanation of server functionality.
71+
Description string `json:"description"`
72+
// Title is an optional human-readable display name.
73+
Title string `json:"title,omitempty"`
74+
// WebsiteURL optionally links to the server's homepage or documentation.
75+
WebsiteURL string `json:"websiteUrl,omitempty"`
76+
// Repository optionally describes the server's source code for inspection.
77+
Repository *Repository `json:"repository,omitempty"`
78+
// Icons optionally lists sized icons a client may render.
79+
Icons []Icon `json:"icons,omitempty"`
80+
// Remotes lists the HTTP-based endpoints for connecting to the server.
81+
Remotes []Remote `json:"remotes,omitempty"`
82+
// Meta carries vendor-specific metadata using reverse-DNS namespacing.
83+
Meta map[string]any `json:"_meta,omitempty"`
84+
}
85+
86+
// Repository describes the MCP server's source code location.
87+
type Repository struct {
88+
// URL is the repository URL for browsing source and cloning.
89+
URL string `json:"url"`
90+
// Source is the hosting service identifier (e.g. "github").
91+
Source string `json:"source"`
92+
// ID is the optional repository identifier owned by the hosting service.
93+
ID string `json:"id,omitempty"`
94+
}
95+
96+
// Remote describes a remote (HTTP-based) MCP server endpoint.
97+
type Remote struct {
98+
// Type is the transport type ("streamable-http" or "sse").
99+
Type string `json:"type"`
100+
// URL is the endpoint URL.
101+
URL string `json:"url"`
102+
// Headers describes HTTP headers required or accepted when connecting.
103+
Headers []KeyValueInput `json:"headers,omitempty"`
104+
}
105+
106+
// Input describes a user-supplied or pre-set value. It is a pragmatic subset of
107+
// the experimental-ext-server-card Input schema, carrying only the fields the
108+
// GitHub card emits.
109+
type Input struct {
110+
// Description is a human-readable explanation of the input.
111+
Description string `json:"description,omitempty"`
112+
// IsRequired indicates the input must be supplied to connect.
113+
IsRequired bool `json:"isRequired,omitempty"`
114+
// IsSecret indicates the value is sensitive and must be handled securely.
115+
IsSecret bool `json:"isSecret,omitempty"`
116+
}
117+
118+
// KeyValueInput is a named Input used to describe an HTTP header.
119+
type KeyValueInput struct {
120+
Input
121+
// Name is the header name.
122+
Name string `json:"name"`
123+
}
124+
125+
// Icon is an optionally-sized icon a client may display.
126+
type Icon struct {
127+
// Src is a URI (HTTP(S) or data:) pointing to an icon resource.
128+
Src string `json:"src"`
129+
// MimeType optionally overrides the source MIME type.
130+
MimeType string `json:"mimeType,omitempty"`
131+
// Sizes optionally lists sizes (e.g. "48x48" or "any") the icon supports.
132+
Sizes []string `json:"sizes,omitempty"`
133+
// Theme optionally indicates the theme ("light" or "dark") the icon suits.
134+
Theme string `json:"theme,omitempty"`
135+
}
136+
137+
// Config controls how the GitHub MCP Server card is built and served.
138+
type Config struct {
139+
// Version is advertised as the card's version and SHOULD match the
140+
// runtime serverInfo version. When empty, "0.0.0-dev" is used.
141+
Version string
142+
143+
// RemoteURL is the absolute streamable-HTTP endpoint advertised in the
144+
// card's single remote. When empty, DefaultRemoteURL is used. The remote
145+
// repository supplies a per-environment URL here.
146+
RemoteURL string
147+
148+
// RemoteURLFunc, when set, derives the streamable-HTTP remote URL from the
149+
// incoming request, taking precedence over RemoteURL whenever it returns a
150+
// non-empty value. This supports multi-tenant deployments (e.g. proxima)
151+
// where the absolute URL varies per request (e.g. from X-Forwarded-Host).
152+
//
153+
// It is consumed by the Handler when serving a card; NewServerCard ignores
154+
// it, since the card constructor is not request-aware.
155+
RemoteURLFunc func(*http.Request) string
156+
}
157+
158+
// NewServerCard builds the GitHub MCP Server's Server Card from cfg.
159+
func NewServerCard(cfg Config) *ServerCard {
160+
version := cfg.Version
161+
if version == "" {
162+
version = "0.0.0-dev"
163+
}
164+
165+
remoteURL := cfg.RemoteURL
166+
if remoteURL == "" {
167+
remoteURL = DefaultRemoteURL
168+
}
169+
170+
// supportedProtocolVersions is intentionally omitted: the go-sdk does not
171+
// export the list of versions it negotiates, so we cannot advertise it
172+
// accurately from the runtime. Omitting it is preferable to publishing a
173+
// hand-maintained list that could drift from what the server actually
174+
// serves.
175+
return &ServerCard{
176+
Schema: SchemaURL,
177+
Name: serverName,
178+
Version: version,
179+
Description: serverDescription,
180+
Title: serverTitle,
181+
WebsiteURL: repositoryURL,
182+
Icons: githubIcons(),
183+
Repository: &Repository{
184+
URL: repositoryURL,
185+
Source: repositorySource,
186+
ID: repositoryID,
187+
},
188+
Remotes: []Remote{
189+
{
190+
Type: "streamable-http",
191+
URL: remoteURL,
192+
Headers: []KeyValueInput{
193+
{
194+
Input: Input{
195+
Description: "Authorization header with authentication token (PAT or App token)",
196+
IsRequired: true,
197+
IsSecret: true,
198+
},
199+
Name: "Authorization",
200+
},
201+
},
202+
},
203+
},
204+
}
205+
}
206+
207+
// githubIcons returns the light- and dark-theme GitHub mark icons as
208+
// self-contained data URIs, reusing the embedded Octicons so the card has no
209+
// external image dependency. The order is fixed so the serialized card — and
210+
// therefore its ETag — is deterministic. It returns nil if the icons are
211+
// unavailable.
212+
func githubIcons() []Icon {
213+
themes := []struct {
214+
octicon octicons.Theme
215+
card string
216+
}{
217+
{octicons.ThemeLight, "light"},
218+
{octicons.ThemeDark, "dark"},
219+
}
220+
221+
var icons []Icon
222+
for _, t := range themes {
223+
if src := octicons.DataURI(iconName, t.octicon); src != "" {
224+
icons = append(icons, Icon{
225+
Src: src,
226+
MimeType: "image/png",
227+
Sizes: []string{iconSize},
228+
Theme: t.card,
229+
})
230+
}
231+
}
232+
return icons
233+
}

0 commit comments

Comments
 (0)