Skip to content

Commit e33dc64

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 and minimal: it advertises identity (name, title, description, version), repository, 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. Vary: Accept is set so shared caches key on the negotiated media type. 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, icons, and per-remote auth metadata are intentionally omitted to keep the card minimal and accurate: the go-sdk does not export negotiated protocol versions, and auth is advertised via OAuth protected-resource-metadata discovery rather than duplicated on the card. 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 e33dc64

6 files changed

Lines changed: 800 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: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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 "net/http"
17+
18+
const (
19+
// SchemaURL is the v1 Server Card JSON Schema URI that emitted cards
20+
// conform to. The schema is versioned by its `vN` path segment.
21+
SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json"
22+
23+
// MediaType is the media type used to serve and request a Server Card.
24+
MediaType = "application/mcp-server-card+json"
25+
26+
// Path is the suffix, relative to a server's streamable-HTTP URL, at which
27+
// MCP reserves the recommended Server Card location. A server hosted at
28+
// `https://host/mcp` therefore serves its card at `https://host/mcp/server-card`.
29+
Path = "/server-card"
30+
31+
// DefaultRemoteURL is the streamable-HTTP endpoint of the hosted GitHub MCP
32+
// Server on github.com. The remote repository overrides this per environment.
33+
DefaultRemoteURL = "https://api.githubcopilot.com/mcp/"
34+
)
35+
36+
// Identity fields reused from the MCP Registry document (server.json) so the
37+
// Server Card and the registry entry describe the same server.
38+
const (
39+
serverName = "io.github.github/github-mcp-server"
40+
serverTitle = "GitHub"
41+
serverDescription = "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language."
42+
repositoryURL = "https://github.com/github/github-mcp-server"
43+
repositorySource = "github"
44+
// repositoryID is the github.com repository ID for github/github-mcp-server.
45+
// It is stable across renames but changes if the repository is recreated.
46+
repositoryID = "942771284"
47+
)
48+
49+
// ServerCard is a static metadata document describing a remote MCP server,
50+
// suitable for pre-connection discovery. It mirrors the ServerCard interface in
51+
// modelcontextprotocol/experimental-ext-server-card. Server Cards are
52+
// remote-only and never carry installable packages.
53+
type ServerCard struct {
54+
// Schema is the Server Card JSON Schema URI this document conforms to.
55+
Schema string `json:"$schema"`
56+
// Name is the server name in reverse-DNS format with exactly one slash.
57+
Name string `json:"name"`
58+
// Version is the server version, equivalent to Implementation.version.
59+
Version string `json:"version"`
60+
// Description is a short, human-readable explanation of server functionality.
61+
Description string `json:"description"`
62+
// Title is an optional human-readable display name.
63+
Title string `json:"title,omitempty"`
64+
// WebsiteURL optionally links to the server's homepage or documentation.
65+
WebsiteURL string `json:"websiteUrl,omitempty"`
66+
// Repository optionally describes the server's source code for inspection.
67+
Repository *Repository `json:"repository,omitempty"`
68+
// Remotes lists the HTTP-based endpoints for connecting to the server.
69+
Remotes []Remote `json:"remotes,omitempty"`
70+
}
71+
72+
// Repository describes the MCP server's source code location.
73+
type Repository struct {
74+
// URL is the repository URL for browsing source and cloning.
75+
URL string `json:"url"`
76+
// Source is the hosting service identifier (e.g. "github").
77+
Source string `json:"source"`
78+
// ID is the optional repository identifier owned by the hosting service.
79+
ID string `json:"id,omitempty"`
80+
}
81+
82+
// Remote describes a remote (HTTP-based) MCP server endpoint. Authentication is
83+
// intentionally not described here: the hosted server advertises its auth
84+
// requirements via OAuth protected-resource-metadata discovery, so duplicating
85+
// them on the card would risk drift and cannot capture every accepted mode.
86+
type Remote struct {
87+
// Type is the transport type ("streamable-http" or "sse").
88+
Type string `json:"type"`
89+
// URL is the endpoint URL.
90+
URL string `json:"url"`
91+
}
92+
93+
// Config controls how the GitHub MCP Server card is built and served.
94+
type Config struct {
95+
// Version is advertised as the card's version and SHOULD match the
96+
// runtime serverInfo version. When empty, "0.0.0-dev" is used.
97+
Version string
98+
99+
// RemoteURL is the absolute streamable-HTTP endpoint advertised in the
100+
// card's single remote. When empty, DefaultRemoteURL is used. The remote
101+
// repository supplies a per-environment URL here.
102+
RemoteURL string
103+
104+
// RemoteURLFunc, when set, derives the streamable-HTTP remote URL from the
105+
// incoming request, taking precedence over RemoteURL whenever it returns a
106+
// non-empty value. This supports multi-tenant deployments (e.g. proxima)
107+
// where the absolute URL varies per request (e.g. from X-Forwarded-Host).
108+
//
109+
// It is consumed by the Handler when serving a card; NewServerCard ignores
110+
// it, since the card constructor is not request-aware.
111+
RemoteURLFunc func(*http.Request) string
112+
}
113+
114+
// NewServerCard builds the GitHub MCP Server's Server Card from cfg.
115+
func NewServerCard(cfg Config) *ServerCard {
116+
version := cfg.Version
117+
if version == "" {
118+
version = "0.0.0-dev"
119+
}
120+
121+
remoteURL := cfg.RemoteURL
122+
if remoteURL == "" {
123+
remoteURL = DefaultRemoteURL
124+
}
125+
126+
// supportedProtocolVersions is intentionally omitted: the go-sdk does not
127+
// export the list of versions it negotiates, so we cannot advertise it
128+
// accurately from the runtime. Omitting it is preferable to publishing a
129+
// hand-maintained list that could drift from what the server actually
130+
// serves.
131+
return &ServerCard{
132+
Schema: SchemaURL,
133+
Name: serverName,
134+
Version: version,
135+
Description: serverDescription,
136+
Title: serverTitle,
137+
WebsiteURL: repositoryURL,
138+
Repository: &Repository{
139+
URL: repositoryURL,
140+
Source: repositorySource,
141+
ID: repositoryID,
142+
},
143+
Remotes: []Remote{
144+
{Type: "streamable-http", URL: remoteURL},
145+
},
146+
}
147+
}

pkg/http/servercard/card_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package servercard
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// assertCardContract checks the required Server Card fields defined by the
13+
// experimental-ext-server-card v1 schema, plus the remote-only invariant. It is
14+
// a focused stand-in for full JSON-Schema validation: the card is small and its
15+
// shape is stable, so asserting the contract's required fields keeps the tests
16+
// self-contained without vendoring the upstream schema.
17+
func assertCardContract(t *testing.T, card *ServerCard) {
18+
t.Helper()
19+
20+
raw, err := json.Marshal(card)
21+
require.NoError(t, err)
22+
23+
var fields map[string]json.RawMessage
24+
require.NoError(t, json.Unmarshal(raw, &fields))
25+
26+
// Required by the schema: $schema, name, version, description.
27+
assert.Equal(t, SchemaURL, card.Schema)
28+
assert.NotEmpty(t, card.Name)
29+
assert.NotEmpty(t, card.Version)
30+
require.NotEmpty(t, card.Description)
31+
assert.LessOrEqual(t, len(card.Description), 100, "description must respect the schema maxLength")
32+
for _, key := range []string{"$schema", "name", "version", "description"} {
33+
assert.Contains(t, fields, key, "required field %q must be serialized", key)
34+
}
35+
36+
// Remote-only: a Server Card never enumerates installable packages — those
37+
// stay in the registry server.json.
38+
assert.NotContains(t, fields, "packages", "Server Card must be remote-only and omit packages")
39+
require.Len(t, card.Remotes, 1)
40+
assert.Equal(t, "streamable-http", card.Remotes[0].Type)
41+
}
42+
43+
func TestNewServerCard(t *testing.T) {
44+
t.Parallel()
45+
46+
tests := []struct {
47+
name string
48+
cfg Config
49+
expectedVersion string
50+
expectedRemoteURL string
51+
}{
52+
{
53+
name: "defaults",
54+
cfg: Config{},
55+
expectedVersion: "0.0.0-dev",
56+
expectedRemoteURL: DefaultRemoteURL,
57+
},
58+
{
59+
name: "explicit version",
60+
cfg: Config{Version: "1.2.3"},
61+
expectedVersion: "1.2.3",
62+
expectedRemoteURL: DefaultRemoteURL,
63+
},
64+
{
65+
name: "per-environment remote URL",
66+
cfg: Config{Version: "1.2.3", RemoteURL: "https://api.example.test/mcp/"},
67+
expectedVersion: "1.2.3",
68+
expectedRemoteURL: "https://api.example.test/mcp/",
69+
},
70+
}
71+
72+
for _, tc := range tests {
73+
t.Run(tc.name, func(t *testing.T) {
74+
t.Parallel()
75+
76+
card := NewServerCard(tc.cfg)
77+
78+
// Identity is reused from the registry document (server.json) and
79+
// is locked: the AI Catalog derives its urn:air: id from the name.
80+
assert.Equal(t, "io.github.github/github-mcp-server", card.Name)
81+
assert.Equal(t, "GitHub", card.Title)
82+
assert.True(t, strings.HasPrefix(card.Description, "Connect AI assistants to GitHub"))
83+
assert.Equal(t, tc.expectedVersion, card.Version)
84+
assert.Equal(t, "https://github.com/github/github-mcp-server", card.WebsiteURL)
85+
86+
require.NotNil(t, card.Repository)
87+
assert.Equal(t, "https://github.com/github/github-mcp-server", card.Repository.URL)
88+
assert.Equal(t, "github", card.Repository.Source)
89+
assert.Equal(t, "942771284", card.Repository.ID)
90+
91+
assert.Equal(t, tc.expectedRemoteURL, card.Remotes[0].URL)
92+
93+
assertCardContract(t, card)
94+
})
95+
}
96+
}

0 commit comments

Comments
 (0)