Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/docker-mcp/commands/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/server-entry-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/interceptors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/minimal-compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions examples/playwright-with-extrahosts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions pkg/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion pkg/catalog_next/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pkg/catalog_next/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion pkg/catalog_next/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
25 changes: 7 additions & 18 deletions pkg/catalog_next/server_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package catalognext

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/goccy/go-yaml"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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()
Expand All @@ -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,
},
},
},
Expand All @@ -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
})
Comment on lines +133 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestInspectServerWithReadme now injects a fake fetchReadme that only asserts URL equality, so it no longer exercises the real fetch.Untrusted path (and therefore the new upgrade + the https/SSRF guard) on the inspect flow this PR fixes. And inspectServer fetches the persisted ReadmeURL as-is (server.go:62-65) — normalizeCatalogServerURLs only runs at create time — so for any catalog persisted before this PR, fetch.Untrusted's internal upgrade is the only thing rescuing an http://desktop.docker.com readme at inspect time.

The upgrade itself is well covered at the unit seam (remoteurl_test.go, fetch_test.go::TestUntrustedRejectsUnknownHTTPURL, create_test.go), so this is minor — but one end-to-end assertion through the exported InspectServer (or fetch.Untrusted directly) with an http://desktop.docker.com readme would restore a regression tripwire for the exact inspect scenario the PR is fixing.

require.NoError(t, err)
})

Expand Down
2 changes: 2 additions & 0 deletions pkg/fetch/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 4 additions & 15 deletions pkg/fetch/fetch_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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")
}
4 changes: 2 additions & 2 deletions pkg/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions pkg/remoteurl/remoteurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely scoped — exact-host match (EqualFold) + port `` / 80 only, and not touching `allowInsecure` keeps the SSRF/IP-pinning guard live on the upgraded request. Host-confusion vectors all fail closed: `evil.desktop.docker.com` and `desktop.docker.com.evil.example` don't match the host so stay `http` (then rejected downstream), and `http://desktop.docker.com@attacker.example/` keeps `attacker.example` as the host so also stays `http`. No change requested here — just confirming the boundary is right.

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 == "" {
Expand Down
40 changes: 40 additions & 0 deletions pkg/remoteurl/remoteurl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading