Skip to content
Merged
24 changes: 21 additions & 3 deletions docs/guides/publishing/publish-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,18 +238,36 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
```

### How It Works
- Registry authenticates with Docker Hub using public token
- Registry authenticates with container registries using token-based authentication:
- **Docker Hub**: Uses `auth.docker.io` token service
- **GitHub Container Registry**: Uses `ghcr.io` token service
- Fetches image manifest using Docker Registry v2 API
- Checks that `io.modelcontextprotocol.server.name` annotation matches your server name
- Fails if annotation is missing or doesn't match

### Example server.json
### Example server.json (Docker Hub)
```json
{
"name": "io.github.username/server-name",
"packages": [
{
"registry_type": "oci",
"registry_base_url": "https://docker.io",
"identifier": "yourusername/your-mcp-server",
"version": "1.0.0"
}
]
}
```

### Example server.json (GitHub Container Registry)
```json
{
"name": "io.github.username/server-name",
"packages": [
{
"registry_type": "oci",
"registry_base_url": "https://ghcr.io",
"identifier": "yourusername/your-mcp-server",
"version": "1.0.0"

Choose a reason for hiding this comment

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

is version here means image tag?

Choose a reason for hiding this comment

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

btw, any changes to github action approach? can github action approach been automatic?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @SamYuan1990, yes version here refers to the image tag. The existing Docker Hub implementation also referred to it as version so wanted to keep it consistent and not introduce any breaking changes.

I have not looked at the GitHub actions tbh, do you have any suggestions?

Choose a reason for hiding this comment

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

I suppose Github action doc can be updated, but fully support with automation seems other story.
https://github.com/modelcontextprotocol/registry/blob/main/docs/guides/publishing/github-actions.md

I suppose maybe we can ask LLM to see if we can just use some github action default env values. as $repo or something? to build the json file.

Choose a reason for hiding this comment

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

Don't understand your question.

Is it for how to make the yaml file to publish on ghcr? Have a look at the repo readme, they clearly outline what's required.

For an example, look here: https://github.com/nkapila6/mcp-local-rag/blob/main/.github%2Fworkflows%2Fpublish-image.yml#L31

Copy link

@SamYuan1990 SamYuan1990 Sep 15, 2025

Choose a reason for hiding this comment

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

ref https://docs.github.com/en/actions/reference/workflows-and-actions/variables
follow show me the code
the json file seems to be:


{
  "name": "io.github.$GITHUB_ACTION_REPOSITORY", 
  "packages": [
    {
      "registry_type": "oci",
      "registry_base_url": "https://ghcr.io",
      "identifier": "$GITHUB_ACTION_REPOSITORY",
      "version": "container_version"
}

Note for container version your can figure out from $GITHUB_REF or hardcode.


I hope my understand is correct as $GITHUB_ACTION_REPOSITORY in Github action is repo name, and identifier is your container package name which same as your repo name?
so... when we using github action to build and upload a container into ghcr.... we can one step further as how to generate this json file from github action env. (maybe a shell script?)

}
Expand All @@ -259,7 +277,7 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"

The identifier is `namespace/repository`, and version is the tag and optionally digest.

The official MCP registry currently only supports the official Docker registry (`https://docker.io`).
The official MCP registry currently supports Docker Hub (`https://docker.io`) and GitHub Container Registry (`https://ghcr.io`).

</details>

Expand Down
176 changes: 126 additions & 50 deletions internal/validators/registries/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package registries
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
Expand All @@ -14,13 +15,47 @@ import (

const (
dockerIoAPIBaseURL = "https://registry-1.docker.io"
ghcrAPIBaseURL = "https://ghcr.io"
)

// OCIAuthResponse represents the Docker Hub authentication response
// ErrRateLimited is returned when a registry rate limits our requests
var ErrRateLimited = errors.New("rate limited by registry")

// OCIAuthResponse represents an OCI registry authentication response
type OCIAuthResponse struct {
Token string `json:"token"`
}

// RegistryConfig holds configuration for different OCI registries
type RegistryConfig struct {
APIBaseURL string
AuthURL string
Service string
Scope string
}

// getRegistryConfig returns the configuration for a specific registry
func getRegistryConfig(registryBaseURL, namespace, repo string) *RegistryConfig {
switch registryBaseURL {
case model.RegistryURLDocker:
return &RegistryConfig{
APIBaseURL: dockerIoAPIBaseURL,
AuthURL: "https://auth.docker.io/token",
Service: "registry.docker.io",
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
}
case model.RegistryURLGHCR:
return &RegistryConfig{
APIBaseURL: ghcrAPIBaseURL,
AuthURL: fmt.Sprintf("%s/token", ghcrAPIBaseURL),
Service: "ghcr.io",
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
}
default:
return nil
}
}

// OCIManifest represents an OCI image manifest
type OCIManifest struct {
Manifests []struct {
Expand All @@ -45,10 +80,9 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
pkg.RegistryBaseURL = model.RegistryURLDocker
}

// Validate that the registry base URL matches OCI/Docker exactly
if pkg.RegistryBaseURL != model.RegistryURLDocker {
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
pkg.RegistryBaseURL, model.RegistryTypeOCI, model.RegistryURLDocker)
// Validate that the registry base URL is supported
if err := validateRegistryURL(pkg.RegistryBaseURL); err != nil {
return err
}

client := &http.Client{Timeout: 10 * time.Second}
Expand All @@ -59,75 +93,112 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
return fmt.Errorf("invalid OCI image reference: %w", err)
}

apiBaseURL := pkg.RegistryBaseURL
if pkg.RegistryBaseURL == model.RegistryURLDocker {
// docker.io is an exceptional registry that was created before standardisation, so needs a custom API base url
// https://github.com/containers/image/blob/5e4845dddd57598eb7afeaa6e0f4c76531bd3c91/docker/docker_client.go#L225-L229
apiBaseURL = dockerIoAPIBaseURL
// Get registry configuration
registryConfig := getRegistryConfig(pkg.RegistryBaseURL, namespace, repo)
if registryConfig == nil {
return fmt.Errorf("unsupported registry: %s", pkg.RegistryBaseURL)
}

// Get the image manifest
manifest, err := fetchImageManifest(ctx, client, registryConfig, namespace, repo, pkg.Version)
if err != nil {
// Handle rate limiting explicitly - skip validation
if errors.Is(err, ErrRateLimited) {
log.Printf("Skipping OCI validation for %s/%s:%s due to rate limiting", namespace, repo, pkg.Version)
return nil
}
return err
}

// Get config digest from manifest
configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, namespace, repo, manifest)
if err != nil {
return err
}

// Validate server name annotation
return validateServerNameAnnotation(ctx, client, registryConfig, namespace, repo, pkg.Version, configDigest, serverName)
}

// validateRegistryURL validates that the registry base URL is supported
func validateRegistryURL(registryURL string) error {
if registryURL != model.RegistryURLDocker && registryURL != model.RegistryURLGHCR {
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s or %s",
registryURL, model.RegistryTypeOCI, model.RegistryURLDocker, model.RegistryURLGHCR)
}
return nil
}

tag := pkg.Version
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, tag)
// fetchImageManifest fetches the OCI manifest for an image
func fetchImageManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag string) (*OCIManifest, error) {
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, tag)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return fmt.Errorf("failed to create manifest request: %w", err)
return nil, fmt.Errorf("failed to create manifest request: %w", err)
}

// Get auth token for docker.io
// We only support auth for docker.io, other registries must allow unauthed requests
if apiBaseURL == dockerIoAPIBaseURL {
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
// Get auth token if registry requires it
if registryConfig.AuthURL != "" {
token, err := getRegistryAuthToken(ctx, client, registryConfig)
if err != nil {
return fmt.Errorf("failed to authenticate with Docker registry: %w", err)
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
}

req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json")
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json")
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch OCI manifest: %w", err)
return nil, fmt.Errorf("failed to fetch OCI manifest: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
return nil, fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
}
if resp.StatusCode == http.StatusTooManyRequests {
// Rate limited, skip validation for now
log.Printf("Warning: Rate limited when accessing OCI image '%s/%s:%s'. Skipping validation.", namespace, repo, tag)
return nil
// Rate limited, return explicit error
log.Printf("Rate limited when accessing OCI image '%s/%s:%s'", namespace, repo, tag)
return nil, fmt.Errorf("%w: %s/%s:%s", ErrRateLimited, namespace, repo, tag)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
return nil, fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
}

var manifest OCIManifest
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return fmt.Errorf("failed to parse OCI manifest: %w", err)
return nil, fmt.Errorf("failed to parse OCI manifest: %w", err)
}

return &manifest, nil
}

// getConfigDigestFromManifest extracts the config digest from an OCI manifest
func getConfigDigestFromManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo string, manifest *OCIManifest) (string, error) {
// Handle multi-arch images by using first manifest
var configDigest string
if len(manifest.Manifests) > 0 {
// This is a multi-arch image, get the specific manifest
specificManifest, err := getSpecificManifest(ctx, client, apiBaseURL, namespace, repo, manifest.Manifests[0].Digest)
specificManifest, err := getSpecificManifest(ctx, client, registryConfig, namespace, repo, manifest.Manifests[0].Digest)
if err != nil {
return fmt.Errorf("failed to get specific manifest: %w", err)
return "", fmt.Errorf("failed to get specific manifest: %w", err)
}
configDigest = specificManifest.Config.Digest
} else {
configDigest = manifest.Config.Digest
return specificManifest.Config.Digest, nil
}

if configDigest == "" {
return fmt.Errorf("unable to determine image config digest for '%s/%s:%s'", namespace, repo, tag)
// For single-arch images, validate we have a config digest
if manifest.Config.Digest == "" {
return "", fmt.Errorf("manifest missing config digest - invalid or corrupted manifest")
}

return manifest.Config.Digest, nil
}

// validateServerNameAnnotation validates the MCP server name annotation in the image config
func validateServerNameAnnotation(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag, configDigest, serverName string) error {
// Get image config (contains labels)
config, err := getImageConfig(ctx, client, apiBaseURL, namespace, repo, configDigest)
config, err := getImageConfig(ctx, client, registryConfig, namespace, repo, configDigest)
if err != nil {
return fmt.Errorf("failed to get image config: %w", err)
}
Expand Down Expand Up @@ -156,9 +227,13 @@ func parseImageReference(identifier string) (string, string, error) {
}
}

// getDockerIoAuthToken retrieves an authentication token from Docker Hub
func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, repo string) (string, error) {
authURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s/%s:pull", namespace, repo)
// getRegistryAuthToken retrieves an authentication token from a registry
func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) {
if config.AuthURL == "" {
return "", nil // No auth required
}

authURL := fmt.Sprintf("%s?service=%s&scope=%s", config.AuthURL, config.Service, config.Scope)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil)
if err != nil {
Expand All @@ -183,19 +258,20 @@ func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, r
return authResp.Token, nil
}


// getSpecificManifest retrieves a specific manifest for multi-arch images
func getSpecificManifest(ctx context.Context, client *http.Client, apiBaseURL, namespace, repo, digest string) (*OCIManifest, error) {
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, digest)
func getSpecificManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, digest string) (*OCIManifest, error) {
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, digest)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create specific manifest request: %w", err)
}

// Get auth token for docker.io
if apiBaseURL == dockerIoAPIBaseURL {
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
// Get auth token if registry requires it
if registryConfig.AuthURL != "" {
token, err := getRegistryAuthToken(ctx, client, registryConfig)
if err != nil {
return nil, fmt.Errorf("failed to authenticate with Docker registry: %w", err)
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
}
Expand All @@ -222,18 +298,18 @@ func getSpecificManifest(ctx context.Context, client *http.Client, apiBaseURL, n
}

// getImageConfig retrieves the image configuration containing labels
func getImageConfig(ctx context.Context, client *http.Client, apiBaseURL, namespace, repo, configDigest string) (*OCIImageConfig, error) {
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", apiBaseURL, namespace, repo, configDigest)
func getImageConfig(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, configDigest string) (*OCIImageConfig, error) {
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", registryConfig.APIBaseURL, namespace, repo, configDigest)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create config request: %w", err)
}

// Get auth token for docker.io
if apiBaseURL == dockerIoAPIBaseURL {
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
// Get auth token if registry requires it
if registryConfig.AuthURL != "" {
token, err := getRegistryAuthToken(ctx, client, registryConfig)
if err != nil {
return nil, fmt.Errorf("failed to authenticate with Docker registry: %w", err)
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
}
Expand Down
Loading
Loading