Skip to content

Commit 8f08a37

Browse files
authored
Add support for creating catalogs from community registry (#406)
This PR adds support for creating a catalog from the community registry (https://registry.modelcontextprotocol.io/) using mcp-gateway. A CI job will publish the catalog as an OCI artifact and upload it to Docker Hub under the mcp namespace `mcp/community-registry`. Users can then manually pull it using `docker mcp catalog pull mcp/community-registry`, keeping it opt-in for now. The --from-community-registry flag will be used here as an additional CI step to publish the catalog as an OCI artifact: https://github.com/docker/ai-mcp/pull/132/changes **What I did** * Add `--from-community-registry` flag to `docker mcp catalog create` to create a catalog with all (oci + remotes for now) community registry servers * Transforms compatible servers (OCI stdio + remotes) into catalog entries * Skips incompatible servers (npm, pypi, etc.) with a summary to stderr for unknown errors. These will be supported later on * Tag imported servers with "community" metadata **Testing** ``` ❯ docker mcp catalog list Reference | Digest | Title mcp/docker-mcp-catalog:latest | c08094360f8d91dd62cedc03ca16fda9df9ea1512e6336294890759b79e1f5f1 | Docker MCP Catalog ❯ docker mcp catalog create justinchang41497/community-registry \ --from-community-registry registry.modelcontextprotocol.io \ --title "MCP Community Registry" Fetched 806 servers from registry.modelcontextprotocol.io Total in registry: 1962 Imported: 806 OCI (stdio): 101 Remote: 705 Skipped: 1156 npm: 696 pypi: 338 no packages: 61 mcpb: 37 nuget: 15 oci: 9 Catalog justinchang41497/community-registry:latest created ❯ docker mcp catalog create justinchang41497/community-registry \ --from-community-registry registry.modelcontextprotocol.io \ --title "MCP Community Registry" ❯ docker mcp catalog list Reference | Digest | Title justinchang41497/community-registry:latest | 9f1a00d2a804c0513065de2b8c7ed5df501bbe1cf7cd505d5c2f485e6206d94c | MCP Community Registry mcp/docker-mcp-catalog:latest | c08094360f8d91dd62cedc03ca16fda9df9ea1512e6336294890759b79e1f5f1 | Docker MCP Catalog ``` Push catalog as OCI artifact: ``` ❯ docker mcp catalog push justinchang41497/community-registry Pushed catalog to justinchang41497/community-registry:latest@sha256:9f1a00d2a804c0513065de2b8c7ed5df501bbe1cf7cd505d5c2f485e6206d94c ``` Pulling catalog directly instead of creating it: ``` ❯ docker mcp catalog pull justinchang41497/community-registry Catalog justinchang41497/community-registry:latest pulled ``` **Related issue** <!-- If this is a bug fix, make sure your description includes "fixes #xxxx", or "closes #xxxx" --> **(not mandatory) A picture of a cute animal, if possible in relation to what you did** <img width="612" height="545" alt="image" src="https://github.com/user-attachments/assets/c1580ff2-bd47-458b-9392-b5340fedc7e6" />
1 parent 660627e commit 8f08a37

File tree

8 files changed

+400
-38
lines changed

8 files changed

+400
-38
lines changed

cmd/docker-mcp/commands/catalog_next.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,30 @@ func catalogNextCommand() *cobra.Command {
3535

3636
func createCatalogNextCommand() *cobra.Command {
3737
var opts struct {
38-
Title string
39-
FromWorkingSet string
40-
FromLegacyCatalog string
41-
Servers []string
38+
Title string
39+
FromWorkingSet string
40+
FromLegacyCatalog string
41+
FromCommunityRegistry string
42+
Servers []string
4243
}
4344

4445
cmd := &cobra.Command{
45-
Use: "create <oci-reference> [--server <ref1> --server <ref2> ...] [--from-profile <profile-id>] [--from-legacy-catalog <url>] [--title <title>]",
46-
Short: "Create a new catalog from a profile or legacy catalog",
46+
Use: "create <oci-reference> [--server <ref1> --server <ref2> ...] [--from-profile <profile-id>] [--from-legacy-catalog <url>] [--from-community-registry <hostname>] [--title <title>]",
47+
Short: "Create a new catalog from a profile, legacy catalog, or community registry",
4748
Args: cobra.ExactArgs(1),
4849
RunE: func(cmd *cobra.Command, args []string) error {
49-
if opts.FromWorkingSet != "" && opts.FromLegacyCatalog != "" {
50-
return fmt.Errorf("cannot use both --from-profile and --from-legacy-catalog")
50+
sourceCount := 0
51+
if opts.FromWorkingSet != "" {
52+
sourceCount++
53+
}
54+
if opts.FromLegacyCatalog != "" {
55+
sourceCount++
56+
}
57+
if opts.FromCommunityRegistry != "" {
58+
sourceCount++
59+
}
60+
if sourceCount > 1 {
61+
return fmt.Errorf("only one of --from-profile, --from-legacy-catalog, or --from-community-registry can be specified")
5162
}
5263

5364
dao, err := db.New()
@@ -56,14 +67,15 @@ func createCatalogNextCommand() *cobra.Command {
5667
}
5768
registryClient := registryapi.NewClient()
5869
ociService := oci.NewService()
59-
return catalognext.Create(cmd.Context(), dao, registryClient, ociService, args[0], opts.Servers, opts.FromWorkingSet, opts.FromLegacyCatalog, opts.Title)
70+
return catalognext.Create(cmd.Context(), dao, registryClient, ociService, args[0], opts.Servers, opts.FromWorkingSet, opts.FromLegacyCatalog, opts.FromCommunityRegistry, opts.Title)
6071
},
6172
}
6273

6374
flags := cmd.Flags()
6475
flags.StringArrayVar(&opts.Servers, "server", []string{}, "Server to include specified with a URI: https:// (MCP Registry reference) or docker:// (Docker Image reference) or catalog:// (Catalog reference) or file:// (Local file path). Can be specified multiple times.")
6576
flags.StringVar(&opts.FromWorkingSet, "from-profile", "", "Profile ID to create the catalog from")
6677
flags.StringVar(&opts.FromLegacyCatalog, "from-legacy-catalog", "", "Legacy catalog URL to create the catalog from")
78+
flags.StringVar(&opts.FromCommunityRegistry, "from-community-registry", "", "Community registry hostname to fetch servers from (e.g. registry.modelcontextprotocol.io)")
6779
flags.StringVar(&opts.Title, "title", "", "Title of the catalog")
6880

6981
return cmd

pkg/catalog/registry_to_catalog.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package catalog
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"strings"
78

@@ -11,6 +12,10 @@ import (
1112
"github.com/docker/mcp-gateway/pkg/registryapi"
1213
)
1314

15+
// ErrIncompatibleServer is returned by TransformToDocker when the server has
16+
// no compatible package type (e.g. no OCI+stdio package and no remote).
17+
var ErrIncompatibleServer = errors.New("incompatible server")
18+
1419
// Type aliases for imported types from the registry package
1520
type (
1621
ServerDetail = v0.ServerJSON
@@ -380,7 +385,7 @@ func TransformToDocker(serverDetail ServerDetail) (*Server, error) {
380385

381386
// Validate that we have at least one way to run the server
382387
if server.Image == "" && server.Remote.URL == "" {
383-
return nil, fmt.Errorf("no OCI packages found")
388+
return nil, fmt.Errorf("%w: no compatible packages for %s", ErrIncompatibleServer, serverDetail.Name)
384389
}
385390

386391
// Add config schema if we have config variables

pkg/catalog_next/catalog.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const (
4545
SourcePrefixLegacyCatalog = "legacy-catalog:"
4646
SourcePrefixOCI = "oci:"
4747
SourcePrefixUser = "user:"
48+
SourcePrefixRegistry = "registry:"
4849
)
4950

5051
type Server struct {

pkg/catalog_next/create.go

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"database/sql"
66
"errors"
77
"fmt"
8+
"os"
89
"slices"
10+
"sort"
911
"strings"
1012
"time"
1113

@@ -19,7 +21,7 @@ import (
1921
"github.com/docker/mcp-gateway/pkg/workingset"
2022
)
2123

22-
func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client, ociService oci.Service, refStr string, servers []string, workingSetID string, legacyCatalogURL string, title string) error {
24+
func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client, ociService oci.Service, refStr string, servers []string, workingSetID string, legacyCatalogURL string, communityRegistryRef string, title string) error {
2325
telemetry.Init()
2426
start := time.Now()
2527
var success bool
@@ -46,10 +48,15 @@ func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client,
4648
if err != nil {
4749
return fmt.Errorf("failed to create catalog from legacy catalog: %w", err)
4850
}
51+
} else if communityRegistryRef != "" {
52+
catalog, err = createCatalogFromCommunityRegistry(ctx, registryClient, communityRegistryRef)
53+
if err != nil {
54+
return fmt.Errorf("failed to create catalog from community registry: %w", err)
55+
}
4956
} else {
5057
// Construct from servers
5158
if title == "" {
52-
return fmt.Errorf("title is required when creating a catalog without using an existing legacy catalog or profile")
59+
return fmt.Errorf("title is required when creating a catalog without using an existing legacy catalog, profile, or community registry")
5360
}
5461
catalog = Catalog{
5562
CatalogArtifact: CatalogArtifact{
@@ -191,3 +198,139 @@ func workingSetServerToCatalogServer(server workingset.Server) Server {
191198
Snapshot: server.Snapshot,
192199
}
193200
}
201+
202+
type communityRegistryResult struct {
203+
serversAdded int
204+
serversOCI int
205+
serversRemote int
206+
serversSkipped int
207+
totalServers int
208+
skippedByType map[string]int
209+
}
210+
211+
func createCatalogFromCommunityRegistry(ctx context.Context, registryClient registryapi.Client, registryRef string) (Catalog, error) {
212+
baseURL := "https://" + registryRef
213+
servers, err := registryClient.ListServers(ctx, baseURL, "")
214+
if err != nil {
215+
return Catalog{}, fmt.Errorf("failed to fetch servers from community registry: %w", err)
216+
}
217+
218+
catalogServers := make([]Server, 0)
219+
skippedByType := make(map[string]int)
220+
var ociCount, remoteCount int
221+
222+
for _, serverResp := range servers {
223+
catalogServer, err := legacycatalog.TransformToDocker(serverResp.Server)
224+
if err != nil {
225+
if !errors.Is(err, legacycatalog.ErrIncompatibleServer) {
226+
fmt.Fprintf(os.Stderr, "Warning: failed to transform server %q: %v\n", serverResp.Server.Name, err)
227+
}
228+
if len(serverResp.Server.Packages) > 0 {
229+
skippedByType[serverResp.Server.Packages[0].RegistryType]++
230+
} else {
231+
skippedByType["none"]++
232+
}
233+
continue
234+
}
235+
236+
// Tag with "community" for source identification
237+
if catalogServer.Metadata == nil {
238+
catalogServer.Metadata = &legacycatalog.Metadata{}
239+
}
240+
catalogServer.Metadata.Tags = appendIfMissing(catalogServer.Metadata.Tags, "community")
241+
242+
var s Server
243+
switch catalogServer.Type {
244+
case "server":
245+
ociCount++
246+
s = Server{
247+
Type: workingset.ServerTypeImage,
248+
Image: catalogServer.Image,
249+
Snapshot: &workingset.ServerSnapshot{
250+
Server: *catalogServer,
251+
},
252+
}
253+
case "remote":
254+
remoteCount++
255+
s = Server{
256+
Type: workingset.ServerTypeRemote,
257+
Endpoint: catalogServer.Remote.URL,
258+
Snapshot: &workingset.ServerSnapshot{
259+
Server: *catalogServer,
260+
},
261+
}
262+
default:
263+
continue
264+
}
265+
catalogServers = append(catalogServers, s)
266+
}
267+
268+
slices.SortStableFunc(catalogServers, func(a, b Server) int {
269+
return strings.Compare(a.Snapshot.Server.Name, b.Snapshot.Server.Name)
270+
})
271+
272+
result := communityRegistryResult{
273+
serversAdded: len(catalogServers),
274+
serversOCI: ociCount,
275+
serversRemote: remoteCount,
276+
serversSkipped: totalSkipped(skippedByType),
277+
totalServers: len(servers),
278+
skippedByType: skippedByType,
279+
}
280+
printCommunityRegistryResult(registryRef, result)
281+
282+
return Catalog{
283+
CatalogArtifact: CatalogArtifact{
284+
Title: "MCP Community Registry",
285+
Servers: catalogServers,
286+
},
287+
Source: SourcePrefixRegistry + registryRef,
288+
}, nil
289+
}
290+
291+
func totalSkipped(skippedByType map[string]int) int {
292+
total := 0
293+
for _, count := range skippedByType {
294+
total += count
295+
}
296+
return total
297+
}
298+
299+
func printCommunityRegistryResult(refStr string, result communityRegistryResult) {
300+
fmt.Fprintf(os.Stderr, "Fetched %d servers from %s\n", result.serversAdded, refStr)
301+
fmt.Fprintf(os.Stderr, " Total in registry: %d\n", result.totalServers)
302+
fmt.Fprintf(os.Stderr, " Imported: %d\n", result.serversAdded)
303+
fmt.Fprintf(os.Stderr, " OCI (stdio): %d\n", result.serversOCI)
304+
fmt.Fprintf(os.Stderr, " Remote: %d\n", result.serversRemote)
305+
fmt.Fprintf(os.Stderr, " Skipped: %d\n", result.serversSkipped)
306+
307+
if len(result.skippedByType) > 0 {
308+
type typeCount struct {
309+
name string
310+
count int
311+
}
312+
var sorted []typeCount
313+
for t, c := range result.skippedByType {
314+
sorted = append(sorted, typeCount{t, c})
315+
}
316+
sort.Slice(sorted, func(i, j int) bool {
317+
return sorted[i].count > sorted[j].count
318+
})
319+
for _, tc := range sorted {
320+
label := tc.name
321+
if label == "none" {
322+
label = "no packages"
323+
}
324+
fmt.Fprintf(os.Stderr, " %-17s%d\n", label+":", tc.count)
325+
}
326+
}
327+
}
328+
329+
func appendIfMissing(slice []string, val string) []string {
330+
for _, item := range slice {
331+
if item == val {
332+
return slice
333+
}
334+
}
335+
return append(slice, val)
336+
}

0 commit comments

Comments
 (0)