Skip to content
Open
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
34 changes: 34 additions & 0 deletions docs/modelcontextprotocol-io/package-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,37 @@ openssl dgst -sha256 image-processor.mcpb
```

The MCP Registry does not validate this hash; however, MCP clients **do** validate the hash before installation to ensure file integrity. Downstream registries may also implement their own validation.

## Go Packages

For Go packages, the MCP Registry currently supports modules hosted on GitHub and resolved through the official Go module proxy (`https://proxy.golang.org`).

Go packages use `"registryType": "go"` in `server.json`, with the module path as the `identifier`. For example:

```json server.json highlight={9}
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.username/audit-mcp",
"title": "Audit",
"description": "Audit MCP traffic",
"version": "1.0.0",
"packages": [
{
"registryType": "go",
"identifier": "github.com/username/audit-mcp",
"version": "v1.0.0",
"transport": {
"type": "stdio"
}
}
]
}
```

The `version` is the Go module version (e.g. `v1.0.0`, including the leading `v`), and the registry confirms it exists on the Go module proxy.

### Ownership Verification

Go modules are identified by their source location, so ownership is verified through the publisher's GitHub namespace rather than an embedded marker. A server published under the `io.github.<owner>` namespace **MUST** use a module path rooted at `github.com/<owner>/...` (the owner is compared case-insensitively). Because the publisher already authenticates for the `io.github.<owner>` namespace, matching the module owner proves ownership.

Non-GitHub module paths are not yet supported.
2 changes: 2 additions & 0 deletions internal/validators/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func ValidatePackage(ctx context.Context, pkg model.Package, serverName string)
return registries.ValidateOCI(ctx, pkg, serverName)
case model.RegistryTypeMCPB:
return registries.ValidateMCPB(ctx, pkg, serverName)
case model.RegistryTypeGo:
return registries.ValidateGo(ctx, pkg, serverName)
default:
return fmt.Errorf("unsupported registry type: %s", pkg.RegistryType)
}
Expand Down
152 changes: 152 additions & 0 deletions internal/validators/registries/go.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package registries

import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/modelcontextprotocol/registry/pkg/model"
)

var (
ErrMissingIdentifierForGo = errors.New("package identifier (the module path, e.g. github.com/owner/repo) is required for Go packages")
ErrMissingVersionForGo = errors.New("package version is required for Go packages")
)

const (
goGitHubNamespacePrefix = "io.github."
goGitHubModulePrefix = "github.com/"
goProxyRequestTimeout = 10 * time.Second
)

// ValidateGo validates that a Go module package is owned by the publisher and
// exists on the Go module proxy.
//
// Ownership: a Go module path is its own source location, so for the v1
// GitHub-namespace model a server published under "io.github.<owner>" must use
// a module path rooted at "github.com/<owner>/...". The registry already
// authenticated the publisher for that GitHub namespace, so matching the owner
// segment proves ownership without an mcpName-style marker (which would not be
// idiomatic in a go.mod). Non-GitHub module paths are intentionally left for a
// follow-up.
//
// Existence: the module version is verified against the Go module proxy
// (proxy.golang.org), the canonical source of truth.
func ValidateGo(ctx context.Context, pkg model.Package, serverName string) error {
if pkg.RegistryBaseURL == "" {
pkg.RegistryBaseURL = model.RegistryURLGoProxy
}
if pkg.Identifier == "" {
return ErrMissingIdentifierForGo
}
if pkg.Version == "" {
return ErrMissingVersionForGo
}
// Validate that MCPB-specific fields are not present
if pkg.FileSHA256 != "" {
return fmt.Errorf("go packages must not have 'fileSha256' field - this is only for MCPB packages")
}
// Validate that the registry base URL matches the Go module proxy exactly
if pkg.RegistryBaseURL != model.RegistryURLGoProxy {
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
pkg.RegistryBaseURL, model.RegistryTypeGo, model.RegistryURLGoProxy)
}

if err := ValidateGoModuleOwnership(pkg.Identifier, serverName); err != nil {
return err
}

return ValidateGoModuleExists(ctx, pkg.RegistryBaseURL, pkg.Identifier, pkg.Version)
}

// ValidateGoModuleOwnership checks that the module path is rooted at the GitHub
// owner that owns the server's io.github.* namespace. Comparison is
// case-insensitive because GitHub owners are case-insensitive.
func ValidateGoModuleOwnership(modulePath, serverName string) error {
namespace, _, found := strings.Cut(serverName, "/")
if !found || !strings.HasPrefix(namespace, goGitHubNamespacePrefix) {
return fmt.Errorf("go packages are currently only supported for 'io.github.*' server namespaces; '%s' is not supported", serverName)
}
owner := strings.ToLower(strings.TrimPrefix(namespace, goGitHubNamespacePrefix))

lowerModule := strings.ToLower(modulePath)
if !strings.HasPrefix(lowerModule, goGitHubModulePrefix) {
return fmt.Errorf("go module path '%s' must be hosted on github.com to be published under '%s'", modulePath, namespace)
}
moduleOwner, _, _ := strings.Cut(lowerModule[len(goGitHubModulePrefix):], "/")
if moduleOwner == "" {
return fmt.Errorf("go module path '%s' is missing an owner segment", modulePath)
}
if moduleOwner != owner {
return fmt.Errorf("go module ownership validation failed: server '%s' is owned by GitHub user '%s', but module path '%s' belongs to '%s'", serverName, owner, modulePath, moduleOwner)
}
return nil
}

// ValidateGoModuleExists confirms the module version is available on the Go
// module proxy at baseURL.
func ValidateGoModuleExists(ctx context.Context, baseURL, modulePath, version string) error {
escapedModule, err := EscapeGoModulePath(modulePath)
if err != nil {
return fmt.Errorf("invalid Go module path '%s': %w", modulePath, err)
}
escapedVersion, err := EscapeGoModulePath(version)
if err != nil {
return fmt.Errorf("invalid Go module version '%s': %w", version, err)
}

requestURL := fmt.Sprintf("%s/%s/@v/%s.info", strings.TrimRight(baseURL, "/"), escapedModule, escapedVersion)

client := &http.Client{Timeout: goProxyRequestTimeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch module metadata from the Go module proxy: %w", err)
}
defer resp.Body.Close()

switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusNotFound, http.StatusGone:
return fmt.Errorf("go module '%s' version '%s' was not found on the Go module proxy. If you published it recently, allow a few minutes for the proxy to fetch it", modulePath, version)
default:
return fmt.Errorf("go module proxy returned unexpected status %d for '%s@%s'", resp.StatusCode, modulePath, version)
}
}

// EscapeGoModulePath applies the goproxy case-encoding: every uppercase ASCII
// letter is replaced by '!' followed by its lowercase form. It rejects control
// characters and "."/".." path elements so a crafted identifier cannot escape
// the proxy path.
func EscapeGoModulePath(s string) (string, error) {
if s == "" {
return "", errors.New("empty path")
}
for _, segment := range strings.Split(s, "/") {
if segment == "." || segment == ".." {
return "", fmt.Errorf("contains a %q path element", segment)
}
}
var b strings.Builder
for _, r := range s {
switch {
case r >= 'A' && r <= 'Z':
b.WriteByte('!')
b.WriteRune(r + ('a' - 'A'))
case r < 0x20 || r == 0x7f:
return "", errors.New("contains a control character")
default:
b.WriteRune(r)
}
}
return b.String(), nil
}
155 changes: 155 additions & 0 deletions internal/validators/registries/go_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package registries_test

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/modelcontextprotocol/registry/internal/validators/registries"
"github.com/modelcontextprotocol/registry/pkg/model"
)

func TestValidateGoModuleOwnership(t *testing.T) {
cases := []struct {
name string
modulePath string
serverName string
wantErr bool
}{
{"matching owner", "github.com/alice/mcp-foo", "io.github.alice/foo", false},
{"matching owner with subpackage path", "github.com/alice/mcp-foo/cmd/foo", "io.github.alice/foo", false},
{"case-insensitive owner match", "github.com/Alice/mcp-foo", "io.github.alice/foo", false},
{"case-insensitive namespace match", "github.com/alice/mcp-foo", "io.github.Alice/foo", false},
{"owner mismatch", "github.com/bob/mcp-foo", "io.github.alice/foo", true},
{"module not on github", "gitlab.com/alice/mcp-foo", "io.github.alice/foo", true},
{"non-github namespace", "github.com/alice/mcp-foo", "com.example/foo", true},
{"missing owner segment", "github.com/", "io.github.alice/foo", true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := registries.ValidateGoModuleOwnership(tc.modulePath, tc.serverName)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateGoModuleOwnership(%q, %q) error = %v, wantErr %v", tc.modulePath, tc.serverName, err, tc.wantErr)
}
})
}
}

func TestEscapeGoModulePath(t *testing.T) {
cases := []struct {
name string
in string
want string
wantErr bool
}{
{"lowercase unchanged", "github.com/alice/mcp-foo", "github.com/alice/mcp-foo", false},
{"uppercase escaped", "github.com/Alice/McpFoo", "github.com/!alice/!mcp!foo", false},
{"version unchanged", "v1.2.3", "v1.2.3", false},
{"parent dir rejected", "github.com/alice/../bob", "", true},
{"current dir rejected", "github.com/alice/./foo", "", true},
{"empty rejected", "", "", true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := registries.EscapeGoModulePath(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("EscapeGoModulePath(%q) error = %v, wantErr %v", tc.in, err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Errorf("EscapeGoModulePath(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

func TestValidateGoModuleExists(t *testing.T) {
cases := []struct {
name string
status int
wantErr bool
wantSubstr string
}{
{"found", http.StatusOK, false, ""},
{"not found", http.StatusNotFound, true, "not found"},
{"gone", http.StatusGone, true, "not found"},
{"server error", http.StatusInternalServerError, true, "unexpected status"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(tc.status)
}))
defer srv.Close()

err := registries.ValidateGoModuleExists(context.Background(), srv.URL, "github.com/Alice/mcp-foo", "v1.0.0")
if (err != nil) != tc.wantErr {
t.Fatalf("ValidateGoModuleExists() error = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr && !strings.Contains(err.Error(), tc.wantSubstr) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantSubstr)
}
// The module path is case-encoded for the proxy ('Alice' -> '!alice').
if want := "/github.com/!alice/mcp-foo/@v/v1.0.0.info"; gotPath != want {
t.Errorf("proxy request path = %q, want %q", gotPath, want)
}
})
}
}

func TestValidateGoGuardErrors(t *testing.T) {
ctx := context.Background()
base := model.Package{
RegistryType: model.RegistryTypeGo,
Identifier: "github.com/alice/mcp-foo",
Version: "v1.0.0",
}
server := "io.github.alice/foo"

t.Run("missing identifier", func(t *testing.T) {
pkg := base
pkg.Identifier = ""
if err := registries.ValidateGo(ctx, pkg, server); err == nil {
t.Error("expected error for missing identifier")
}
})

t.Run("missing version", func(t *testing.T) {
pkg := base
pkg.Version = ""
if err := registries.ValidateGo(ctx, pkg, server); err == nil {
t.Error("expected error for missing version")
}
})

t.Run("fileSha256 rejected", func(t *testing.T) {
pkg := base
pkg.FileSHA256 = "deadbeef"
if err := registries.ValidateGo(ctx, pkg, server); err == nil {
t.Error("expected error for fileSha256 on go package")
}
})

t.Run("base URL mismatch", func(t *testing.T) {
pkg := base
pkg.RegistryBaseURL = "https://example.com"
if err := registries.ValidateGo(ctx, pkg, server); err == nil {
t.Error("expected error for non-proxy base URL")
}
})

t.Run("ownership failure returns before network", func(t *testing.T) {
pkg := base
pkg.Identifier = "github.com/someone-else/mcp-foo"
err := registries.ValidateGo(ctx, pkg, server)
if err == nil || !strings.Contains(err.Error(), "ownership validation failed") {
t.Errorf("expected ownership validation error, got %v", err)
}
})
}
14 changes: 8 additions & 6 deletions pkg/model/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ const (
RegistryTypeOCI = "oci"
RegistryTypeNuGet = "nuget"
RegistryTypeMCPB = "mcpb"
RegistryTypeGo = "go"
)

// Registry Base URLs - supported package registry base URLs
const (
RegistryURLGitHub = "https://github.com"
RegistryURLGitLab = "https://gitlab.com"
RegistryURLNPM = "https://registry.npmjs.org"
RegistryURLNuGet = "https://api.nuget.org/v3/index.json"
RegistryURLPyPI = "https://pypi.org"
RegistryURLQuay = "https://quay.io"
RegistryURLGitHub = "https://github.com"
RegistryURLGitLab = "https://gitlab.com"
RegistryURLNPM = "https://registry.npmjs.org"
RegistryURLNuGet = "https://api.nuget.org/v3/index.json"
RegistryURLPyPI = "https://pypi.org"
RegistryURLQuay = "https://quay.io"
RegistryURLGoProxy = "https://proxy.golang.org"
)

// Transport Types - supported remote transport protocols
Expand Down
Loading