diff --git a/cmd/docker-mcp/commands/import.go b/cmd/docker-mcp/commands/import.go index 0421dcdd2..1cb8d51ce 100644 --- a/cmd/docker-mcp/commands/import.go +++ b/cmd/docker-mcp/commands/import.go @@ -13,6 +13,8 @@ import ( ) func runMcpregistryImport(ctx context.Context, serverURL string, servers *[]catalog.Server) error { + serverURL = remoteurl.UpgradeKnownHTTPURLToHTTPS(serverURL) + if err := remoteurl.Validate(ctx, serverURL); err != nil { return fmt.Errorf("invalid URL: %w", err) } diff --git a/docs/mcp-gateway.md b/docs/mcp-gateway.md index 79c269963..28583008a 100644 --- a/docs/mcp-gateway.md +++ b/docs/mcp-gateway.md @@ -70,7 +70,7 @@ services: + Starts an MCP Gateway for other services to use. Think AI Agents. + Work independently from Docker Desktop's MCP Toolkit. It can run anywhere there's a Docker engine. + Defines the list of enabled servers from the gateway's command line, with `--server` -+ Uses the online Docker MCP Catalog (v2: http://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: http://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ++ Uses the online Docker MCP Catalog (v2: https://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: https://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ### How to run diff --git a/docs/server-entry-spec.md b/docs/server-entry-spec.md index 6b97522bc..1a9fb4c6f 100644 --- a/docs/server-entry-spec.md +++ b/docs/server-entry-spec.md @@ -4,7 +4,7 @@ This document defines the specification for MCP server entries in the Docker MCP Server entries can be defined for an mcp server by writing a yaml file and using it as a CLI flag for profiles or catalogs via `--server file://my-server.yaml`. -**A note about legacy catalogs:** Legacy catalogs such as `.docker/mcp/catalogs/docker-mcp.yaml` or http://desktop.docker.com/mcp/catalog/v3/catalog.yaml use a similar schema for servers under the `registry` property. However, this spec is intended for defining server configurations for MCP Profiles and OCI Catalogs. Thus, it's expected that this spec will drift from what exists in legacy catalogs. +**A note about legacy catalogs:** Legacy catalogs such as `.docker/mcp/catalogs/docker-mcp.yaml` or https://desktop.docker.com/mcp/catalog/v3/catalog.yaml use a similar schema for servers under the `registry` property. However, this spec is intended for defining server configurations for MCP Profiles and OCI Catalogs. Thus, it's expected that this spec will drift from what exists in legacy catalogs. ## Example Server Entry YAML diff --git a/examples/client/README.md b/examples/client/README.md index 5deecfa10..ff7aaf0cd 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -4,7 +4,7 @@ This example shows how to call the MCP Gateway from a python client: + Doesn't rely on the MCP Toolkit UI. Can run anywhere, even if Docker Desktop is not available. + Defines the list of enabled servers from the gateway's command line, with `--server` -+ Uses the online Docker MCP Catalog (v2: http://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: http://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ++ Uses the online Docker MCP Catalog (v2: https://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: https://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). + Uses the latest http streaming transport. ## How to run diff --git a/examples/interceptors/README.md b/examples/interceptors/README.md index 96d6205ac..80955e2bd 100644 --- a/examples/interceptors/README.md +++ b/examples/interceptors/README.md @@ -4,7 +4,7 @@ This example shows how to call the MCP Gateway from a python client: + Doesn't rely on the MCP Toolkit UI. Can run anywhere, even if Docker Desktop is not available. + Defines the list of enabled servers from the gateway's command line, with `--server` -+ Uses the online Docker MCP Catalog (v2: http://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: http://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ++ Uses the online Docker MCP Catalog (v2: https://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: https://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). + Uses the latest http streaming transport. ## How to run diff --git a/examples/minimal-compose/README.md b/examples/minimal-compose/README.md index 59e544812..5ab1d3662 100644 --- a/examples/minimal-compose/README.md +++ b/examples/minimal-compose/README.md @@ -5,7 +5,7 @@ This is a very minimalist example of running the MCP Gateway with Docker Compose + Doesn't rely on the MCP Toolkit UI. Can run anywhere, even if Docker Desktop is not available. + Defines the list of enabled servers from the gateway's command line, with `--server` + Doesn't define any secret. -+ Uses the online Docker MCP Catalog (v2: http://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: http://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ++ Uses the online Docker MCP Catalog (v2: https://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: https://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ## How to run diff --git a/examples/playwright-with-extrahosts.yaml b/examples/playwright-with-extrahosts.yaml index c9b51ad7a..b678ac22c 100644 --- a/examples/playwright-with-extrahosts.yaml +++ b/examples/playwright-with-extrahosts.yaml @@ -7,8 +7,8 @@ registry: dateAdded: "2025-05-05T20:04:34Z" image: mcp/playwright@sha256:53da89d1da3dfbb61c10f707c1713cfee1f870f7fba5334e126c6c765e37db56 ref: "" - readme: http://desktop.docker.com/mcp/catalog/v3/readme/playwright.md - toolsUrl: http://desktop.docker.com/mcp/catalog/v3/tools/playwright.json + readme: https://desktop.docker.com/mcp/catalog/v3/readme/playwright.md + toolsUrl: https://desktop.docker.com/mcp/catalog/v3/tools/playwright.json source: https://github.com/microsoft/playwright-mcp/tree/64f65ccd105842204e3e1b1e1a7b28825492b089 upstream: https://github.com/microsoft/playwright-mcp icon: https://avatars.githubusercontent.com/u/6154722?v=4 diff --git a/examples/secrets/README.md b/examples/secrets/README.md index a283b12f8..90b135014 100644 --- a/examples/secrets/README.md +++ b/examples/secrets/README.md @@ -5,7 +5,7 @@ This is a simple example of running the MCP Gateway with Docker Compose: + Doesn't rely on the MCP Toolkit UI. Can run anywhere, even if Docker Desktop is not available. + Defines the list of enabled servers from the gateway's command line, with `--server` + Defines secrets in an `.env` file. -+ Uses the online Docker MCP Catalog (v2: http://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: http://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ++ Uses the online Docker MCP Catalog (v2: https://desktop.docker.com/mcp/catalog/v2/catalog.yaml by default, v3: https://desktop.docker.com/mcp/catalog/v3/catalog.yaml when `mcp-oauth-dcr` feature is enabled). ## How to run diff --git a/pkg/catalog/catalog.go b/pkg/catalog/catalog.go index ae4426d4a..1fb4b408e 100644 --- a/pkg/catalog/catalog.go +++ b/pkg/catalog/catalog.go @@ -82,6 +82,8 @@ func readMCPServers(ctx context.Context, fileOrURL string) (map[string]Server, s func readFileOrURL(ctx context.Context, fileOrURL string) ([]byte, error) { switch { case isURL(fileOrURL): + fileOrURL = remoteurl.UpgradeKnownHTTPURLToHTTPS(fileOrURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileOrURL, nil) if err != nil { return nil, err diff --git a/pkg/catalog_next/create.go b/pkg/catalog_next/create.go index 62cbca392..36eb84ff9 100644 --- a/pkg/catalog_next/create.go +++ b/pkg/catalog_next/create.go @@ -19,6 +19,7 @@ import ( "github.com/docker/mcp-gateway/pkg/db" "github.com/docker/mcp-gateway/pkg/oci" "github.com/docker/mcp-gateway/pkg/registryapi" + "github.com/docker/mcp-gateway/pkg/remoteurl" "github.com/docker/mcp-gateway/pkg/telemetry" "github.com/docker/mcp-gateway/pkg/workingset" ) @@ -162,6 +163,7 @@ func createCatalogFromLegacyCatalog(ctx context.Context, legacyCatalogURL string servers := make([]Server, 0, len(legacyCatalog.Servers)) for name, server := range legacyCatalog.Servers { + server = normalizeCatalogServerURLs(server) if server.Type == "server" && server.Image != "" { s := Server{ Type: workingset.ServerTypeImage, @@ -203,16 +205,28 @@ func createCatalogFromLegacyCatalog(ctx context.Context, legacyCatalogURL string } func workingSetServerToCatalogServer(server workingset.Server) Server { + snapshot := server.Snapshot + if snapshot != nil { + snapshotCopy := *snapshot + snapshotCopy.Server = normalizeCatalogServerURLs(snapshotCopy.Server) + snapshot = &snapshotCopy + } + return Server{ Type: server.Type, Tools: server.Tools, Source: server.Source, Image: server.Image, Endpoint: server.Endpoint, - Snapshot: server.Snapshot, + Snapshot: snapshot, } } +func normalizeCatalogServerURLs(server legacycatalog.Server) legacycatalog.Server { + server.ReadmeURL = remoteurl.UpgradeKnownHTTPURLToHTTPS(server.ReadmeURL) + return server +} + type communityRegistryResult struct { serversAdded int serversOCI int diff --git a/pkg/catalog_next/create_test.go b/pkg/catalog_next/create_test.go index a4c3d1748..f6a96532e 100644 --- a/pkg/catalog_next/create_test.go +++ b/pkg/catalog_next/create_test.go @@ -402,6 +402,7 @@ registry: type: "server" image: "docker/test-server:latest" description: "A test server" + readme: "http://desktop.docker.com/mcp/catalog/v3/readme/test-server.md" server2: title: "Test Server 2" type: "server" @@ -439,6 +440,7 @@ registry: assert.Equal(t, workingset.ServerTypeImage, catalog.Servers[0].Type) assert.Equal(t, "docker/test-server:latest", catalog.Servers[0].Image) assert.Equal(t, "A test server", catalog.Servers[0].Snapshot.Server.Description) + assert.Equal(t, "https://desktop.docker.com/mcp/catalog/v3/readme/test-server.md", catalog.Servers[0].Snapshot.Server.ReadmeURL) assert.Equal(t, "server2", catalog.Servers[1].Snapshot.Server.Name) assert.Equal(t, "Test Server 2", catalog.Servers[1].Snapshot.Server.Title) diff --git a/pkg/catalog_next/server.go b/pkg/catalog_next/server.go index eb5929e5e..b357b106a 100644 --- a/pkg/catalog_next/server.go +++ b/pkg/catalog_next/server.go @@ -30,6 +30,10 @@ type serverFilter struct { } func InspectServer(ctx context.Context, dao db.DAO, catalogRef string, serverName string, format workingset.OutputFormat) error { + return inspectServer(ctx, dao, catalogRef, serverName, format, fetch.Untrusted) +} + +func inspectServer(ctx context.Context, dao db.DAO, catalogRef string, serverName string, format workingset.OutputFormat, fetchReadme func(context.Context, string) ([]byte, error)) error { ref, err := name.ParseReference(catalogRef) if err != nil { return fmt.Errorf("failed to parse oci-reference %s: %w", catalogRef, err) @@ -58,7 +62,7 @@ func InspectServer(ctx context.Context, dao db.DAO, catalogRef string, serverNam } if server.Snapshot != nil && server.Snapshot.Server.ReadmeURL != "" { - readmeContent, err := fetch.Untrusted(ctx, server.Snapshot.Server.ReadmeURL) + readmeContent, err := fetchReadme(ctx, server.Snapshot.Server.ReadmeURL) if err != nil { return fmt.Errorf("failed to fetch readme: %w", err) } diff --git a/pkg/catalog_next/server_test.go b/pkg/catalog_next/server_test.go index f6c25a9b8..7fc423ebb 100644 --- a/pkg/catalog_next/server_test.go +++ b/pkg/catalog_next/server_test.go @@ -1,9 +1,8 @@ package catalognext import ( + "context" "encoding/json" - "net/http" - "net/http/httptest" "testing" "github.com/goccy/go-yaml" @@ -12,7 +11,6 @@ import ( "github.com/docker/mcp-gateway/pkg/catalog" "github.com/docker/mcp-gateway/pkg/desktop" - "github.com/docker/mcp-gateway/pkg/remoteurl" "github.com/docker/mcp-gateway/pkg/workingset" "github.com/docker/mcp-gateway/test/mocks" ) @@ -99,20 +97,8 @@ func TestInspectServer(t *testing.T) { } func TestInspectServerWithReadme(t *testing.T) { - t.Setenv(remoteurl.AllowInsecureRemoteURLEnv, "1") - readmeContent := "# Notion Remote\n\nThis is a remote server" - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/readme.md": - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write([]byte(readmeContent)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(func() { server.Close() }) + readmeURL := "https://desktop.docker.com/mcp/catalog/v3/readme/notion.md" dao := setupTestDB(t) ctx := t.Context() @@ -130,7 +116,7 @@ func TestInspectServerWithReadme(t *testing.T) { Server: catalog.Server{ Name: "my-server", Description: "My test server", - ReadmeURL: server.URL + "/readme.md", + ReadmeURL: readmeURL, }, }, }, @@ -144,7 +130,10 @@ func TestInspectServerWithReadme(t *testing.T) { require.NoError(t, err) output := captureStdout(t, func() { - err := InspectServer(ctx, dao, catalogObj.Ref, "my-server", workingset.OutputFormatJSON) + err := inspectServer(ctx, dao, catalogObj.Ref, "my-server", workingset.OutputFormatJSON, func(_ context.Context, url string) ([]byte, error) { + assert.Equal(t, readmeURL, url) + return []byte(readmeContent), nil + }) require.NoError(t, err) }) diff --git a/pkg/fetch/fetch.go b/pkg/fetch/fetch.go index 79655a34f..6e1b48a54 100644 --- a/pkg/fetch/fetch.go +++ b/pkg/fetch/fetch.go @@ -14,6 +14,8 @@ import ( // The body is limited to 5MB to prevent abuse. // The timeout is 30 seconds. func Untrusted(ctx context.Context, url string) ([]byte, error) { + url = remoteurl.UpgradeKnownHTTPURLToHTTPS(url) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err diff --git a/pkg/fetch/fetch_test.go b/pkg/fetch/fetch_test.go index 1bd86454a..ac8d2212e 100644 --- a/pkg/fetch/fetch_test.go +++ b/pkg/fetch/fetch_test.go @@ -1,14 +1,10 @@ package fetch import ( - "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/docker/mcp-gateway/pkg/remoteurl" ) func TestUntrustedRejectsUnsafeURL(t *testing.T) { @@ -17,15 +13,8 @@ func TestUntrustedRejectsUnsafeURL(t *testing.T) { assert.Contains(t, err.Error(), "not allowed") } -func TestUntrustedAllowsLocalHTTPWithOptIn(t *testing.T) { - t.Setenv(remoteurl.AllowInsecureRemoteURLEnv, "1") - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("readme")) - })) - t.Cleanup(server.Close) - - got, err := Untrusted(t.Context(), server.URL) - require.NoError(t, err) - assert.Equal(t, []byte("readme"), got) +func TestUntrustedRejectsUnknownHTTPURL(t *testing.T) { + _, err := Untrusted(t.Context(), "http://example.com/readme.md") + require.Error(t, err) + assert.Contains(t, err.Error(), "remote URL must use https") } diff --git a/pkg/integration_test.go b/pkg/integration_test.go index 86b45b2c0..d6a6095b6 100644 --- a/pkg/integration_test.go +++ b/pkg/integration_test.go @@ -73,8 +73,8 @@ func createClickhouseCatalogFile(t *testing.T) string { dateAdded: "2025-06-12T18:00:16Z" image: mcp/clickhouse@sha256:3a18fb4687c2f08364fd27be4bb3a7f33e2c77b22d3bca2760d22dcb73e47108 ref: "" - readme: http://desktop.docker.com/mcp/catalog/v2/readme/clickhouse.md - toolsUrl: http://desktop.docker.com/mcp/catalog/v2/tools/clickhouse.json + readme: https://desktop.docker.com/mcp/catalog/v2/readme/clickhouse.md + toolsUrl: https://desktop.docker.com/mcp/catalog/v2/tools/clickhouse.json source: https://github.com/ClickHouse/mcp-clickhouse/tree/main upstream: https://github.com/ClickHouse/mcp-clickhouse icon: https://avatars.githubusercontent.com/u/54801242?v=4 diff --git a/pkg/remoteurl/remoteurl.go b/pkg/remoteurl/remoteurl.go index df3376ad9..c856fef1a 100644 --- a/pkg/remoteurl/remoteurl.go +++ b/pkg/remoteurl/remoteurl.go @@ -54,6 +54,32 @@ func Validate(ctx context.Context, rawURL string) error { return DefaultValidator().Validate(ctx, rawURL) } +// UpgradeKnownHTTPURLToHTTPS rewrites legacy public HTTP URLs that are known to +// serve the same content over HTTPS. It deliberately does not relax validation +// for arbitrary HTTP URLs. +func UpgradeKnownHTTPURLToHTTPS(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + if !strings.EqualFold(u.Scheme, "http") { + return rawURL + } + if !strings.EqualFold(u.Hostname(), "desktop.docker.com") { + return rawURL + } + switch u.Port() { + case "": + u.Scheme = "https" + case "80": + u.Scheme = "https" + u.Host = u.Hostname() + default: + return rawURL + } + return u.String() +} + func (v Validator) Validate(ctx context.Context, rawURL string) error { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { diff --git a/pkg/remoteurl/remoteurl_test.go b/pkg/remoteurl/remoteurl_test.go index 9c05ffa65..fe7d87055 100644 --- a/pkg/remoteurl/remoteurl_test.go +++ b/pkg/remoteurl/remoteurl_test.go @@ -74,6 +74,46 @@ func TestValidateAllowsPublicHTTPS(t *testing.T) { require.NoError(t, validator.Validate(context.Background(), "https://public.example.test/mcp")) } +func TestUpgradeKnownHTTPURLToHTTPS(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "docker desktop catalog asset", + in: "http://desktop.docker.com/mcp/catalog/v3/readme/aws.md", + want: "https://desktop.docker.com/mcp/catalog/v3/readme/aws.md", + }, + { + name: "docker desktop default HTTP port", + in: "http://desktop.docker.com:80/mcp/catalog/v3/catalog.yaml", + want: "https://desktop.docker.com/mcp/catalog/v3/catalog.yaml", + }, + { + name: "already HTTPS", + in: "https://desktop.docker.com/mcp/catalog/v3/catalog.yaml", + want: "https://desktop.docker.com/mcp/catalog/v3/catalog.yaml", + }, + { + name: "arbitrary HTTP remains HTTP", + in: "http://example.com/readme.md", + want: "http://example.com/readme.md", + }, + { + name: "non-default port is not rewritten", + in: "http://desktop.docker.com:8080/mcp/catalog/v3/catalog.yaml", + want: "http://desktop.docker.com:8080/mcp/catalog/v3/catalog.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, UpgradeKnownHTTPURLToHTTPS(tt.in)) + }) + } +} + func TestValidateURLWithoutResolutionDoesNotResolveHost(t *testing.T) { validator := NewValidator(Options{ Resolver: fakeResolver{},