Skip to content

Commit bf9a6e8

Browse files
authored
Add support for pypi packages in the registry (#392)
* Add support for pypi packages. * CR feedback. * Lint fix. * Don't allow pypi when importing community registry. * Independent volumes and long lived. * Fix version parser. Handle not found on pypi. * Support hidden `--include-pypi` flag for catalog created from community registry. * Parse regex once. * CR fixes.
1 parent 705a801 commit bf9a6e8

File tree

10 files changed

+880
-147
lines changed

10 files changed

+880
-147
lines changed

cmd/docker-mcp/commands/catalog_next.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func createCatalogNextCommand() *cobra.Command {
4040
FromLegacyCatalog string
4141
FromCommunityRegistry string
4242
Servers []string
43+
IncludePyPI bool
4344
}
4445

4546
cmd := &cobra.Command{
@@ -86,13 +87,17 @@ When using --server without --from-profile, --from-legacy-catalog, or --from-com
8687
return fmt.Errorf("only one of --from-profile, --from-legacy-catalog, or --from-community-registry can be specified")
8788
}
8889

90+
if opts.IncludePyPI && opts.FromCommunityRegistry == "" {
91+
return fmt.Errorf("--include-pypi can only be used when creating a catalog from a community registry")
92+
}
93+
8994
dao, err := db.New()
9095
if err != nil {
9196
return err
9297
}
9398
registryClient := registryapi.NewClient()
9499
ociService := oci.NewService()
95-
return catalognext.Create(cmd.Context(), dao, registryClient, ociService, args[0], opts.Servers, opts.FromWorkingSet, opts.FromLegacyCatalog, opts.FromCommunityRegistry, opts.Title)
100+
return catalognext.Create(cmd.Context(), dao, registryClient, ociService, args[0], opts.Servers, opts.FromWorkingSet, opts.FromLegacyCatalog, opts.FromCommunityRegistry, opts.Title, opts.IncludePyPI)
96101
},
97102
}
98103

@@ -103,6 +108,9 @@ When using --server without --from-profile, --from-legacy-catalog, or --from-com
103108
flags.StringVar(&opts.FromCommunityRegistry, "from-community-registry", "", "Community registry hostname to fetch servers from (e.g. registry.modelcontextprotocol.io)")
104109
flags.StringVar(&opts.Title, "title", "", "Title of the catalog")
105110

111+
flags.BoolVar(&opts.IncludePyPI, "include-pypi", false, "Include PyPI servers when creating a catalog from a community registry")
112+
cmd.Flags().MarkHidden("include-pypi") //nolint:errcheck
113+
106114
return cmd
107115
}
108116

pkg/catalog/pypi.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package catalog
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"regexp"
9+
"strconv"
10+
"time"
11+
12+
"github.com/docker/mcp-gateway/pkg/desktop"
13+
)
14+
15+
const (
16+
defaultPythonVersion = "3.14"
17+
uvBaseImage = "ghcr.io/astral-sh/uv"
18+
)
19+
20+
var pythonVersionRe = regexp.MustCompile(`(~=|==|!=|<=|>=|<|>)\s*(\d+)(?:\.(\d+))?`)
21+
22+
// PyPIVersionResolver resolves the minimum Python version for a PyPI package.
23+
// It returns the minimum Python version string (e.g., "3.10") or empty string if unknown,
24+
// and a boolean indicating whether the package was found.
25+
type PyPIVersionResolver func(ctx context.Context, identifier, version, registryBaseURL string) (string, bool)
26+
27+
type pypiPackageInfo struct {
28+
Info struct {
29+
RequiresPython string `json:"requires_python"`
30+
} `json:"info"`
31+
}
32+
33+
// NewPyPIVersionResolver creates a resolver that queries the PyPI JSON API.
34+
func NewPyPIVersionResolver(httpClient *http.Client) PyPIVersionResolver {
35+
return func(ctx context.Context, identifier, version, registryBaseURL string) (string, bool) {
36+
// Only query PyPI for standard PyPI registry
37+
if registryBaseURL != "" && registryBaseURL != "https://pypi.org" {
38+
return "", true // assume found for non-standard registries
39+
}
40+
41+
var url string
42+
if version != "" {
43+
url = fmt.Sprintf("https://pypi.org/pypi/%s/%s/json", identifier, version)
44+
} else {
45+
url = fmt.Sprintf("https://pypi.org/pypi/%s/json", identifier)
46+
}
47+
48+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
49+
if err != nil {
50+
return "", false
51+
}
52+
53+
resp, err := httpClient.Do(req)
54+
if err != nil {
55+
return "", false
56+
}
57+
defer resp.Body.Close()
58+
59+
if resp.StatusCode != http.StatusOK {
60+
return "", false
61+
}
62+
63+
var info pypiPackageInfo
64+
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
65+
return "", false
66+
}
67+
68+
return parsePythonVersion(info.Info.RequiresPython), true
69+
}
70+
}
71+
72+
// DefaultPyPIVersionResolver creates a resolver using the default HTTP client with proxy transport.
73+
func DefaultPyPIVersionResolver() PyPIVersionResolver {
74+
client := &http.Client{
75+
Transport: desktop.ProxyTransport(),
76+
Timeout: 10 * time.Second,
77+
}
78+
return NewPyPIVersionResolver(client)
79+
}
80+
81+
// parsePythonVersion extracts a pinned major.minor Python version from a PEP 440 specifier.
82+
// Pinning operators (~=, ==, <=, <) resolve to a specific version.
83+
// >= and > mean "this or newer", so we use the latest.
84+
// Examples: "~=3.10" -> "3.10", "==3.12" -> "3.12", ">=3.10" -> "" (use latest),
85+
// "<=3.10" -> "3.10", "<3.10" -> "3.9"
86+
func parsePythonVersion(requiresPython string) string {
87+
if requiresPython == "" {
88+
return ""
89+
}
90+
91+
allMatches := pythonVersionRe.FindAllStringSubmatch(requiresPython, -1)
92+
93+
hasLowerBound := false
94+
for _, m := range allMatches {
95+
if m[1] == ">=" || m[1] == ">" {
96+
hasLowerBound = true
97+
break
98+
}
99+
}
100+
101+
// Look for pinning constraints; these take priority over lower-bound-only specifiers
102+
for _, m := range allMatches {
103+
if m[3] == "" {
104+
continue // no minor version component, skip
105+
}
106+
switch m[1] {
107+
case "~=", "==", "<=":
108+
return fmt.Sprintf("%s.%s", m[2], m[3])
109+
case "<":
110+
minor, err := strconv.Atoi(m[3])
111+
if err != nil || minor <= 0 {
112+
continue
113+
}
114+
return fmt.Sprintf("%s.%d", m[2], minor-1)
115+
}
116+
}
117+
118+
// If a lower bound (>= or >) exists but no pinning constraint was found, use latest
119+
if hasLowerBound {
120+
return ""
121+
}
122+
123+
return ""
124+
}
125+
126+
// pythonVersionToImageTag maps a Python version to the appropriate uv Docker image tag.
127+
func pythonVersionToImageTag(pythonVersion string) string {
128+
if pythonVersion == "" {
129+
pythonVersion = defaultPythonVersion
130+
}
131+
132+
distro := "bookworm-slim"
133+
134+
return fmt.Sprintf("%s:python%s-%s", uvBaseImage, pythonVersion, distro)
135+
}

pkg/catalog/pypi_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package catalog
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParsePythonVersion(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
requiresPython string
11+
want string
12+
}{
13+
{"empty string", "", ""},
14+
{"greater than or equal uses latest", ">=3.10", ""},
15+
{"greater than or equal with upper bound uses latest", ">=3.10,<4", ""},
16+
{"less than or equal uses pinned version", "<=3.10", "3.10"},
17+
{"less than uses pinned version below", "<3.10", "3.9"},
18+
{"less than with upper bound uses pinned version below", "<3.14,>=3.10", "3.13"},
19+
{"less than with upper bound and lower bound uses pinned version below", "<3.13,>=3.12", "3.12"},
20+
{"compatible release pins", "~=3.10", "3.10"},
21+
{"exact version pins", "==3.12", "3.12"},
22+
{"gte with patch uses latest", ">=3.10.2", ""},
23+
{"gte with spaces uses latest", ">= 3.10", ""},
24+
{"garbage input", "foobar", ""},
25+
{"just a number", "3.10", ""},
26+
{"greater than only", ">3.10", ""},
27+
{"multiple gte constraints uses latest", ">=3.8,!=3.9.0,<4.0", ""},
28+
{"compatible release with patch", "~=3.12.1", "3.12"},
29+
{"exact with patch", "==3.11.5", "3.11"},
30+
}
31+
32+
for _, tt := range tests {
33+
t.Run(tt.name, func(t *testing.T) {
34+
got := parsePythonVersion(tt.requiresPython)
35+
if got != tt.want {
36+
t.Errorf("parsePythonVersion(%q) = %q, want %q", tt.requiresPython, got, tt.want)
37+
}
38+
})
39+
}
40+
}
41+
42+
func TestPythonVersionToImageTag(t *testing.T) {
43+
tests := []struct {
44+
name string
45+
pythonVersion string
46+
want string
47+
}{
48+
{"empty defaults to bookworm", "", "ghcr.io/astral-sh/uv:python3.14-bookworm-slim"},
49+
{"3.10 uses bookworm", "3.10", "ghcr.io/astral-sh/uv:python3.10-bookworm-slim"},
50+
{"3.12 uses bookworm", "3.12", "ghcr.io/astral-sh/uv:python3.12-bookworm-slim"},
51+
{"3.13 uses bookworm", "3.13", "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"},
52+
{"3.14 uses bookworm", "3.14", "ghcr.io/astral-sh/uv:python3.14-bookworm-slim"},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
got := pythonVersionToImageTag(tt.pythonVersion)
58+
if got != tt.want {
59+
t.Errorf("pythonVersionToImageTag(%q) = %q, want %q", tt.pythonVersion, got, tt.want)
60+
}
61+
})
62+
}
63+
}

0 commit comments

Comments
 (0)