Skip to content

Commit 377e1d4

Browse files
Add MCP Server Card (SEP-2127) types + handler
Introduce a `servercard` package that defines the GitHub MCP Server's Server Card (SEP-2127) and a public, no-auth HTTP handler that serves it. The Server Card is a static, remote-only discovery document: it reuses the identity fields from the registry `server.json` (name, title, description, repository) and advertises a single streamable-http remote (default https://api.githubcopilot.com/mcp/, overridable per environment). Following the spec it omits installable packages, which stay in the registry document. The handler mirrors the OAuth protected-resource-metadata handler: it serves `application/mcp-server-card+json` with read-only CORS, a one-hour Cache-Control, Accept content negotiation, and no authentication. It is registered at the reserved `<streamable-http-url>/server-card` location (both `/server-card` and `/mcp/server-card`) so the remote server repository can mount it identically. Tests validate emitted cards against the canonical experimental-ext-server-card JSON Schema and cover the handler's headers, media type, and method handling. Refs github/copilot-mcp-core#1855, github/copilot-mcp-core#1853 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9430064 commit 377e1d4

6 files changed

Lines changed: 864 additions & 0 deletions

File tree

pkg/http/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/github/github-mcp-server/pkg/github"
1818
"github.com/github/github-mcp-server/pkg/http/middleware"
1919
"github.com/github/github-mcp-server/pkg/http/oauth"
20+
"github.com/github/github-mcp-server/pkg/http/servercard"
2021
"github.com/github/github-mcp-server/pkg/inventory"
2122
"github.com/github/github-mcp-server/pkg/lockdown"
2223
"github.com/github/github-mcp-server/pkg/observability"
@@ -198,6 +199,12 @@ func RunHTTPServer(cfg ServerConfig) error {
198199
})
199200
logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
200201

202+
r.Group(func(r chi.Router) {
203+
// Register the public, no-auth MCP Server Card (SEP-2127) endpoint.
204+
servercard.NewHandler(servercard.Config{Version: cfg.Version}).RegisterRoutes(r)
205+
})
206+
logger.Info("MCP Server Card endpoint registered", "path", servercard.Path)
207+
201208
addr := resolveListenAddress(cfg.ListenHost, cfg.Port)
202209
httpSvr := http.Server{
203210
Addr: addr,

pkg/http/servercard/card.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
const (
17+
// SchemaURL is the v1 Server Card JSON Schema URI that emitted cards
18+
// conform to. The schema is versioned by its `vN` path segment.
19+
SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json"
20+
21+
// MediaType is the media type used to serve and request a Server Card.
22+
MediaType = "application/mcp-server-card+json"
23+
24+
// Path is the suffix, relative to a server's streamable-HTTP URL, at which
25+
// MCP reserves the recommended Server Card location. A server hosted at
26+
// `https://host/mcp` therefore serves its card at `https://host/mcp/server-card`.
27+
Path = "/server-card"
28+
29+
// DefaultRemoteURL is the streamable-HTTP endpoint of the hosted GitHub MCP
30+
// Server on github.com. The remote repository overrides this per environment.
31+
DefaultRemoteURL = "https://api.githubcopilot.com/mcp/"
32+
)
33+
34+
// Identity fields reused from the MCP Registry document (server.json) so the
35+
// Server Card and the registry entry describe the same server.
36+
const (
37+
serverName = "io.github.github/github-mcp-server"
38+
serverTitle = "GitHub"
39+
serverDescription = "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language."
40+
repositoryURL = "https://github.com/github/github-mcp-server"
41+
repositorySource = "github"
42+
// repositoryID is the github.com repository ID for github/github-mcp-server.
43+
// It is stable across renames and changes if the repository is recreated.
44+
repositoryID = "942771284"
45+
)
46+
47+
// ServerCard is a static metadata document describing a remote MCP server,
48+
// suitable for pre-connection discovery. It mirrors the ServerCard interface in
49+
// modelcontextprotocol/experimental-ext-server-card. Server Cards are
50+
// remote-only and never carry installable packages.
51+
type ServerCard struct {
52+
// Schema is the Server Card JSON Schema URI this document conforms to.
53+
Schema string `json:"$schema"`
54+
// Name is the server name in reverse-DNS format with exactly one slash.
55+
Name string `json:"name"`
56+
// Version is the server version, equivalent to Implementation.version.
57+
Version string `json:"version"`
58+
// Description is a short, human-readable explanation of server functionality.
59+
Description string `json:"description"`
60+
// Title is an optional human-readable display name.
61+
Title string `json:"title,omitempty"`
62+
// WebsiteURL optionally links to the server's homepage or documentation.
63+
WebsiteURL string `json:"websiteUrl,omitempty"`
64+
// Repository optionally describes the server's source code for inspection.
65+
Repository *Repository `json:"repository,omitempty"`
66+
// Icons optionally lists sized icons a client may render.
67+
Icons []Icon `json:"icons,omitempty"`
68+
// Remotes lists the HTTP-based endpoints for connecting to the server.
69+
Remotes []Remote `json:"remotes,omitempty"`
70+
// Meta carries vendor-specific metadata using reverse-DNS namespacing.
71+
Meta map[string]any `json:"_meta,omitempty"`
72+
}
73+
74+
// Repository describes the MCP server's source code location.
75+
type Repository struct {
76+
// URL is the repository URL for browsing source and cloning.
77+
URL string `json:"url"`
78+
// Source is the hosting service identifier (e.g. "github").
79+
Source string `json:"source"`
80+
// Subfolder is an optional clean relative path within a monorepo.
81+
Subfolder string `json:"subfolder,omitempty"`
82+
// ID is the optional repository identifier owned by the hosting service.
83+
ID string `json:"id,omitempty"`
84+
}
85+
86+
// Remote describes a remote (HTTP-based) MCP server endpoint.
87+
type Remote struct {
88+
// Type is the transport type ("streamable-http" or "sse").
89+
Type string `json:"type"`
90+
// URL is the endpoint URL template. Variables in {curly_braces} are
91+
// substituted from Variables before the client connects.
92+
URL string `json:"url"`
93+
// Headers describes HTTP headers required or accepted when connecting.
94+
Headers []KeyValueInput `json:"headers,omitempty"`
95+
// Variables defines values referenced by {curly_braces} in URL and headers.
96+
Variables map[string]Input `json:"variables,omitempty"`
97+
// SupportedProtocolVersions lists MCP protocol versions this endpoint serves.
98+
SupportedProtocolVersions []string `json:"supportedProtocolVersions,omitempty"`
99+
}
100+
101+
// Input is a user-supplied or pre-set value for a remote URL variable or
102+
// header value.
103+
type Input struct {
104+
// Description is a human-readable explanation of the input.
105+
Description string `json:"description,omitempty"`
106+
// IsRequired indicates the input must be supplied to connect.
107+
IsRequired bool `json:"isRequired,omitempty"`
108+
// IsSecret indicates the value is sensitive and must be handled securely.
109+
IsSecret bool `json:"isSecret,omitempty"`
110+
// Format specifies the input format ("string", "number", "boolean", "filepath").
111+
Format string `json:"format,omitempty"`
112+
// Default is the default value for the input.
113+
Default string `json:"default,omitempty"`
114+
// Placeholder is example guidance shown during configuration.
115+
Placeholder string `json:"placeholder,omitempty"`
116+
// Value is a pre-set value that end users should not configure.
117+
Value string `json:"value,omitempty"`
118+
// Choices, when set, constrains the input to one of the listed values.
119+
Choices []string `json:"choices,omitempty"`
120+
}
121+
122+
// KeyValueInput is a named Input used to describe an HTTP header.
123+
type KeyValueInput struct {
124+
Input
125+
// Name is the header name.
126+
Name string `json:"name"`
127+
// Variables defines values referenced by {curly_braces} in Value.
128+
Variables map[string]Input `json:"variables,omitempty"`
129+
}
130+
131+
// Icon is an optionally-sized icon a client may display.
132+
type Icon struct {
133+
// Src is a URI (HTTP(S) or data:) pointing to an icon resource.
134+
Src string `json:"src"`
135+
// MimeType optionally overrides the source MIME type.
136+
MimeType string `json:"mimeType,omitempty"`
137+
// Sizes optionally lists sizes (e.g. "48x48" or "any") the icon supports.
138+
Sizes []string `json:"sizes,omitempty"`
139+
// Theme optionally indicates the theme ("light" or "dark") the icon suits.
140+
Theme string `json:"theme,omitempty"`
141+
}
142+
143+
// Config controls how the GitHub MCP Server card is built.
144+
type Config struct {
145+
// Version is advertised as the card's version and SHOULD match the
146+
// runtime serverInfo version. When empty, "0.0.0-dev" is used.
147+
Version string
148+
149+
// RemoteURL is the absolute streamable-HTTP endpoint advertised in the
150+
// card's single remote. When empty, DefaultRemoteURL is used. The remote
151+
// repository supplies a per-environment URL here.
152+
RemoteURL string
153+
}
154+
155+
// NewServerCard builds the GitHub MCP Server's Server Card from cfg.
156+
func NewServerCard(cfg Config) *ServerCard {
157+
version := cfg.Version
158+
if version == "" {
159+
version = "0.0.0-dev"
160+
}
161+
162+
remoteURL := cfg.RemoteURL
163+
if remoteURL == "" {
164+
remoteURL = DefaultRemoteURL
165+
}
166+
167+
return &ServerCard{
168+
Schema: SchemaURL,
169+
Name: serverName,
170+
Version: version,
171+
Description: serverDescription,
172+
Title: serverTitle,
173+
WebsiteURL: repositoryURL,
174+
Repository: &Repository{
175+
URL: repositoryURL,
176+
Source: repositorySource,
177+
ID: repositoryID,
178+
},
179+
Remotes: []Remote{
180+
{
181+
Type: "streamable-http",
182+
URL: remoteURL,
183+
Headers: []KeyValueInput{
184+
{
185+
Input: Input{
186+
Description: "Authorization header with authentication token (PAT or App token)",
187+
IsRequired: true,
188+
IsSecret: true,
189+
},
190+
Name: "Authorization",
191+
},
192+
},
193+
},
194+
},
195+
}
196+
}

pkg/http/servercard/card_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package servercard
2+
3+
import (
4+
_ "embed"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
9+
"github.com/google/jsonschema-go/jsonschema"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
//go:embed testdata/server-card.schema.json
15+
var serverCardSchema []byte
16+
17+
// resolvedCardSchema parses the embedded experimental-ext-server-card schema and
18+
// returns a resolver rooted at the ServerCard definition, so emitted cards can be
19+
// validated against the canonical JSON Schema.
20+
func resolvedCardSchema(t *testing.T) *jsonschema.Resolved {
21+
t.Helper()
22+
23+
var schema jsonschema.Schema
24+
require.NoError(t, json.Unmarshal(serverCardSchema, &schema))
25+
26+
root := &jsonschema.Schema{
27+
Schema: schema.Schema,
28+
Ref: "#/$defs/ServerCard",
29+
Defs: schema.Defs,
30+
}
31+
32+
resolved, err := root.Resolve(nil)
33+
require.NoError(t, err)
34+
return resolved
35+
}
36+
37+
// assertSchemaValid marshals card and validates it against the ServerCard schema.
38+
func assertSchemaValid(t *testing.T, resolved *jsonschema.Resolved, card *ServerCard) {
39+
t.Helper()
40+
41+
raw, err := json.Marshal(card)
42+
require.NoError(t, err)
43+
44+
var instance any
45+
require.NoError(t, json.Unmarshal(raw, &instance))
46+
47+
require.NoError(t, resolved.Validate(instance), "card must conform to the Server Card schema")
48+
}
49+
50+
func TestNewServerCard(t *testing.T) {
51+
t.Parallel()
52+
53+
resolved := resolvedCardSchema(t)
54+
55+
tests := []struct {
56+
name string
57+
cfg Config
58+
expectedVersion string
59+
expectedRemoteURL string
60+
}{
61+
{
62+
name: "defaults",
63+
cfg: Config{},
64+
expectedVersion: "0.0.0-dev",
65+
expectedRemoteURL: DefaultRemoteURL,
66+
},
67+
{
68+
name: "explicit version",
69+
cfg: Config{Version: "1.2.3"},
70+
expectedVersion: "1.2.3",
71+
expectedRemoteURL: DefaultRemoteURL,
72+
},
73+
{
74+
name: "per-environment remote URL",
75+
cfg: Config{Version: "1.2.3", RemoteURL: "https://api.example.test/mcp/"},
76+
expectedVersion: "1.2.3",
77+
expectedRemoteURL: "https://api.example.test/mcp/",
78+
},
79+
}
80+
81+
for _, tc := range tests {
82+
t.Run(tc.name, func(t *testing.T) {
83+
t.Parallel()
84+
85+
card := NewServerCard(tc.cfg)
86+
87+
assert.Equal(t, SchemaURL, card.Schema)
88+
assert.Equal(t, "io.github.github/github-mcp-server", card.Name)
89+
assert.Equal(t, "GitHub", card.Title)
90+
assert.Equal(t, tc.expectedVersion, card.Version)
91+
assert.LessOrEqual(t, len(card.Description), 100, "description must respect the schema maxLength")
92+
93+
require.NotNil(t, card.Repository)
94+
assert.Equal(t, "https://github.com/github/github-mcp-server", card.Repository.URL)
95+
assert.Equal(t, "github", card.Repository.Source)
96+
assert.Equal(t, "942771284", card.Repository.ID)
97+
98+
require.Len(t, card.Remotes, 1)
99+
assert.Equal(t, "streamable-http", card.Remotes[0].Type)
100+
assert.Equal(t, tc.expectedRemoteURL, card.Remotes[0].URL)
101+
require.Len(t, card.Remotes[0].Headers, 1)
102+
assert.Equal(t, "Authorization", card.Remotes[0].Headers[0].Name)
103+
assert.True(t, card.Remotes[0].Headers[0].IsSecret)
104+
105+
assertSchemaValid(t, resolved, card)
106+
})
107+
}
108+
}
109+
110+
// TestServerCardIsRemoteOnly guards the SEP-2127 requirement that a Server Card
111+
// never enumerates installable packages — those stay in the registry server.json.
112+
func TestServerCardIsRemoteOnly(t *testing.T) {
113+
t.Parallel()
114+
115+
raw, err := json.Marshal(NewServerCard(Config{}))
116+
require.NoError(t, err)
117+
118+
var fields map[string]json.RawMessage
119+
require.NoError(t, json.Unmarshal(raw, &fields))
120+
121+
_, hasPackages := fields["packages"]
122+
assert.False(t, hasPackages, "Server Card must be remote-only and omit packages")
123+
assert.Contains(t, fields, "remotes")
124+
}
125+
126+
// TestServerCardIdentityMatchesRegistry keeps the card's identity fields aligned
127+
// with the static registry document (server.json).
128+
func TestServerCardIdentityMatchesRegistry(t *testing.T) {
129+
t.Parallel()
130+
131+
card := NewServerCard(Config{})
132+
133+
assert.Equal(t, "io.github.github/github-mcp-server", card.Name)
134+
assert.Equal(t, "GitHub", card.Title)
135+
assert.True(t, strings.HasPrefix(card.Description, "Connect AI assistants to GitHub"))
136+
}

0 commit comments

Comments
 (0)