Skip to content

Commit 6d110bf

Browse files
authored
Use distribution/reference for parsing OCI references (#651)
<!-- Provide a brief summary of your changes --> ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> The following PR addresses the feedback from #634 around: * Use a proper library - https://github.com/distribution/reference, instead of the implementing the OCI parsing ourselves (note: left the tests so we can ensure we are not breaking any existing behavior) * Updating one of the error messages to now assume MCPB. ## How Has This Been Tested? <!-- Have you tested this in a real application? Which scenarios were tested? --> ## Breaking Changes <!-- Will users need to update their code or configurations? --> ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [ ] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> Signed-off-by: Radoslav Dimitrov <[email protected]>
1 parent 5aff088 commit 6d110bf

File tree

5 files changed

+38
-74
lines changed

5 files changed

+38
-74
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/beorn7/perks v1.0.1 // indirect
2626
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2727
github.com/davecgh/go-spew v1.1.1 // indirect
28+
github.com/distribution/reference v0.6.0 // indirect
2829
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
2930
github.com/go-logr/logr v1.4.3 // indirect
3031
github.com/go-logr/stdr v1.2.2 // indirect
@@ -34,6 +35,7 @@ require (
3435
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
3536
github.com/jackc/puddle/v2 v2.2.2 // indirect
3637
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
38+
github.com/opencontainers/go-digest v1.0.0 // indirect
3739
github.com/pmezard/go-difflib v1.0.0 // indirect
3840
github.com/prometheus/client_model v0.6.2 // indirect
3941
github.com/prometheus/common v0.66.1 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNm
1111
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1212
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1313
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14+
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
15+
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
1416
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
1517
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
1618
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -44,6 +46,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
4446
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
4547
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
4648
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
49+
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
50+
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
4751
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4852
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
4953
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=

internal/validators/registries/npm.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func ValidateNPM(ctx context.Context, pkg model.Package, serverName string) erro
4343

4444
// Validate that MCPB-specific fields are not present
4545
if pkg.FileSHA256 != "" {
46-
return fmt.Errorf("NPM packages must not have 'fileSha256' field - this is only for MCPB packages")
46+
return fmt.Errorf("NPM packages must not have 'fileSha256' field")
4747
}
4848

4949
// Validate that the registry base URL matches NPM exactly

internal/validators/registries/oci.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
9696
return fmt.Errorf("OCI packages must not have 'version' field - include version in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')")
9797
}
9898
if pkg.FileSHA256 != "" {
99-
return fmt.Errorf("OCI packages must not have 'fileSha256' field - this is only for MCPB packages")
99+
return fmt.Errorf("OCI packages must not have 'fileSha256' field")
100100
}
101101

102102
// Parse the canonical OCI reference from the identifier

internal/validators/registries/oci_ref_parser.go

Lines changed: 30 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ package registries
22

33
import (
44
"fmt"
5-
"regexp"
65
"strings"
6+
7+
"github.com/distribution/reference"
78
)
89

910
const (
10-
// defaultOCIRegistry is the default registry when none is specified
11-
defaultOCIRegistry = "docker.io"
1211
// defaultOCINamespace is the default namespace for official images
1312
defaultOCINamespace = "library"
1413
)
@@ -22,7 +21,7 @@ type OCIReference struct {
2221
Digest string // e.g., "sha256:abc..." (optional)
2322
}
2423

25-
// ParseOCIReference parses a canonical OCI image reference.
24+
// ParseOCIReference parses a canonical OCI image reference using github.com/distribution/reference.
2625
// Supported formats:
2726
// - registry/namespace/image:tag
2827
// - registry/namespace/image@digest
@@ -34,84 +33,43 @@ func ParseOCIReference(ref string) (*OCIReference, error) {
3433
return nil, fmt.Errorf("OCI reference cannot be empty")
3534
}
3635

37-
result := &OCIReference{}
38-
39-
// Split by @ to separate digest
40-
var mainPart string
41-
if idx := strings.Index(ref, "@"); idx >= 0 {
42-
mainPart = ref[:idx]
43-
result.Digest = ref[idx+1:]
44-
45-
// Validate digest format
46-
if !strings.HasPrefix(result.Digest, "sha256:") {
47-
return nil, fmt.Errorf("invalid digest format: must start with 'sha256:'")
48-
}
49-
digestPattern := regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`)
50-
if !digestPattern.MatchString(result.Digest) {
51-
return nil, fmt.Errorf("invalid digest format: must be sha256 followed by 64 hex characters")
52-
}
53-
} else {
54-
mainPart = ref
36+
// Parse using distribution/reference - normalizes short forms to canonical
37+
named, err := reference.ParseNormalizedNamed(ref)
38+
if err != nil {
39+
return nil, fmt.Errorf("invalid OCI reference format: %w", err)
5540
}
5641

57-
// Split by : to separate tag
58-
var pathPart string
59-
if idx := strings.LastIndex(mainPart, ":"); idx >= 0 {
60-
// Check if this looks like a registry with port (e.g., localhost:5000)
61-
// or a tag. If there's a / after the :, it's likely a port.
62-
if idx > 0 && !strings.Contains(mainPart[idx:], "/") {
63-
pathPart = mainPart[:idx]
64-
result.Tag = mainPart[idx+1:]
65-
} else {
66-
pathPart = mainPart
67-
}
68-
} else {
69-
pathPart = mainPart
70-
}
42+
result := &OCIReference{}
43+
44+
// Extract registry (domain)
45+
result.Registry = reference.Domain(named)
7146

72-
// Parse the path (registry/namespace/image or namespace/image or image)
73-
parts := strings.Split(pathPart, "/")
47+
// Extract path (namespace/image or just image)
48+
path := reference.Path(named)
49+
parts := strings.Split(path, "/")
7450

75-
switch len(parts) {
76-
case 1:
77-
// Just image name: "postgres:16" -> docker.io/library/postgres:16
78-
result.Registry = defaultOCIRegistry
51+
// Parse namespace and image from path
52+
if len(parts) == 1 {
53+
// Single part: library/image (docker.io default namespace)
7954
result.Namespace = defaultOCINamespace
8055
result.Image = parts[0]
56+
} else {
57+
// Multiple parts: namespace/image or org/team/image
58+
result.Namespace = strings.Join(parts[:len(parts)-1], "/")
59+
result.Image = parts[len(parts)-1]
60+
}
8161

82-
case 2:
83-
// namespace/image: "owner/repo:tag" -> docker.io/owner/repo:tag
84-
// OR registry/image: "ghcr.io/image" -> ghcr.io/library/image
85-
// Heuristic: if first part looks like a domain (contains . or :), treat as registry
86-
if strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":") {
87-
result.Registry = parts[0]
88-
result.Namespace = defaultOCINamespace
89-
result.Image = parts[1]
90-
} else {
91-
result.Registry = defaultOCIRegistry
92-
result.Namespace = parts[0]
93-
result.Image = parts[1]
94-
}
95-
96-
case 3:
97-
// registry/namespace/image: "ghcr.io/owner/repo:tag"
98-
result.Registry = parts[0]
99-
result.Namespace = parts[1]
100-
result.Image = parts[2]
62+
// Extract tag if present
63+
if tagged, ok := named.(reference.Tagged); ok {
64+
result.Tag = tagged.Tag()
65+
}
10166

102-
default:
103-
// More than 3 parts could be multi-level namespace (e.g., ghcr.io/org/team/repo)
104-
// Take first as registry, last as image, everything in between as namespace
105-
if len(parts) > 3 {
106-
result.Registry = parts[0]
107-
result.Namespace = strings.Join(parts[1:len(parts)-1], "/")
108-
result.Image = parts[len(parts)-1]
109-
} else {
110-
return nil, fmt.Errorf("invalid OCI reference format: %s", ref)
111-
}
67+
// Extract digest if present
68+
if digested, ok := named.(reference.Digested); ok {
69+
result.Digest = digested.Digest().String()
11270
}
11371

114-
// Validate we have either a tag or digest
72+
// Validate we have either a tag or digest (required for MCP registry)
11573
if result.Tag == "" && result.Digest == "" {
11674
return nil, fmt.Errorf("OCI reference must include either a tag or digest: %s", ref)
11775
}

0 commit comments

Comments
 (0)