diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index faf88950f..7538adc3b 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -659,13 +659,14 @@ components: properties: registryType: type: string - description: Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb') + description: Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb', 'maven') examples: - "npm" - "pypi" - "oci" - "nuget" - "mcpb" + - "maven" registryBaseUrl: type: string format: uri @@ -675,6 +676,7 @@ components: - "https://pypi.org" - "https://docker.io" - "https://api.nuget.org/v3/index.json" + - "https://repo.maven.apache.org/maven2" - "https://github.com" - "https://gitlab.com" identifier: @@ -698,7 +700,7 @@ components: runtimeHint: type: string description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present. - examples: [npx, uvx, docker, dnx] + examples: [npx, uvx, docker, dnx, jbang] transport: $ref: '#/components/schemas/LocalTransport' description: Transport protocol configuration for the package diff --git a/docs/reference/server-json/draft/server.schema.json b/docs/reference/server-json/draft/server.schema.json index 5c59335fc..bf05f0729 100644 --- a/docs/reference/server-json/draft/server.schema.json +++ b/docs/reference/server-json/draft/server.schema.json @@ -241,6 +241,7 @@ "https://pypi.org", "https://docker.io", "https://api.nuget.org/v3/index.json", + "https://repo.maven.apache.org/maven2", "https://github.com", "https://gitlab.com" ], @@ -248,13 +249,14 @@ "type": "string" }, "registryType": { - "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')", + "description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb', 'maven')", "examples": [ "npm", "pypi", "oci", "nuget", - "mcpb" + "mcpb", + "maven" ], "type": "string" }, @@ -271,7 +273,8 @@ "npx", "uvx", "docker", - "dnx" + "dnx", + "jbang" ], "type": "string" }, diff --git a/docs/reference/server-json/generic-server-json.md b/docs/reference/server-json/generic-server-json.md index bb5cc44c6..51f75369e 100644 --- a/docs/reference/server-json/generic-server-json.md +++ b/docs/reference/server-json/generic-server-json.md @@ -423,6 +423,49 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6. } ``` +### Maven (JVM) Package Example + +JVM-based MCP servers can be published to Maven Central. The `jbang` runtime +hint matches the typical `jbang org.example:my-mcp-server:1.0.0` invocation. + +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.example/demo-mcp-server", + "description": "Sample JVM MCP server published to Maven Central", + "repository": { + "url": "https://github.com/example/demo-mcp-server", + "source": "github", + "id": "example-maven-id-0000-1111-222222222222" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "maven", + "registryBaseUrl": "https://repo.maven.apache.org/maven2", + "identifier": "io.github.example:demo-mcp-server", + "version": "1.0.0", + "runtimeHint": "jbang", + "transport": { + "type": "stdio" + } + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "tool": "maven-publisher", + "version": "1.0.0" + } + } +} +``` + +Ownership is verified by reading the published POM at +`https://repo.maven.apache.org/maven2////-.pom`. +Add either an `...` property or an `mcp-name: ` +line in the POM `` so the registry can match the package to the +server name. + ### Complex Docker Server with Multiple Arguments ```json diff --git a/docs/reference/server-json/official-registry-requirements.md b/docs/reference/server-json/official-registry-requirements.md index e03b65989..b7d6f5904 100644 --- a/docs/reference/server-json/official-registry-requirements.md +++ b/docs/reference/server-json/official-registry-requirements.md @@ -33,6 +33,7 @@ Only trusted public registries are supported. Private registries and alternative - **NPM**: `https://registry.npmjs.org` only - **PyPI**: `https://pypi.org` only - **NuGet**: `https://api.nuget.org/v3/index.json` only +- **Maven**: `https://repo.maven.apache.org/maven2` only (Maven Central) - **Docker/OCI**: - Docker Hub (`docker.io`) - GitHub Container Registry (`ghcr.io`) diff --git a/internal/validators/package.go b/internal/validators/package.go index 7104f730b..e31ce861a 100644 --- a/internal/validators/package.go +++ b/internal/validators/package.go @@ -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.RegistryTypeMaven: + return registries.ValidateMaven(ctx, pkg, serverName) default: return fmt.Errorf("unsupported registry type: %s", pkg.RegistryType) } diff --git a/internal/validators/registries/maven.go b/internal/validators/registries/maven.go new file mode 100644 index 000000000..56554de1f --- /dev/null +++ b/internal/validators/registries/maven.go @@ -0,0 +1,196 @@ +package registries + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/modelcontextprotocol/registry/pkg/model" +) + +var ( + ErrMissingIdentifierForMaven = errors.New("package identifier is required for Maven packages") + ErrMissingVersionForMaven = errors.New("package version is required for Maven packages") + ErrInvalidMavenIdentifier = errors.New("maven package identifier must be in 'groupId:artifactId' form (e.g., 'org.example:my-mcp-server')") +) + +// mavenPOM represents the subset of Maven POM XML fields used to validate +// MCP server ownership. Only the fields we read are declared. +type mavenPOM struct { + XMLName xml.Name `xml:"project"` + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Description string `xml:"description"` + Properties struct { + // Maven properties is a free-form element; we look for an + // `mcpName` property as the most explicit ownership signal. + MCPName string `xml:"mcpName"` + } `xml:"properties"` + // Parent POM may set groupId for the project; we do not currently + // follow parent inheritance because the published POM should declare + // its own groupId for ownership purposes. +} + +// ValidateMaven validates that a Maven Central package contains the correct +// MCP server name. Identifier is "groupId:artifactId" (e.g., "org.example:my-mcp-server") +// and Version must be a concrete release (no version ranges). +// +// Ownership is verified by fetching the published POM at: +// +// {baseURL}/{groupPath}/{artifactId}/{version}/{artifactId}-{version}.pom +// +// and looking for the server name as either: +// 1. an `` Maven property, or +// 2. an `mcp-name: ` line in the POM ``. +// +// Maven Central already validates `groupId` domain ownership at publish time, +// so a matching declaration in the POM is sufficient to bind a package to an +// MCP server name in the same namespace. +func ValidateMaven(ctx context.Context, pkg model.Package, serverName string) error { + if pkg.RegistryBaseURL == "" { + pkg.RegistryBaseURL = model.RegistryURLMaven + } + + if pkg.Identifier == "" { + return ErrMissingIdentifierForMaven + } + if pkg.Version == "" { + return ErrMissingVersionForMaven + } + + // Maven packages must not carry MCPB-only fields. + if pkg.FileSHA256 != "" { + return fmt.Errorf("maven packages must not have 'fileSha256' field - this is only for MCPB packages") + } + + // Maven Central is the only allowed base URL for the official registry. + if pkg.RegistryBaseURL != model.RegistryURLMaven { + return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s", + pkg.RegistryBaseURL, model.RegistryTypeMaven, model.RegistryURLMaven) + } + + return validateMavenAgainst(ctx, pkg.RegistryBaseURL, pkg, serverName) +} + +// validateMavenAgainst is the common validation routine, parameterized on +// the base URL so tests can point it at an httptest server without going +// through the Maven Central allowlist check. +func validateMavenAgainst(ctx context.Context, baseURL string, pkg model.Package, serverName string) error { + if pkg.Identifier == "" { + return ErrMissingIdentifierForMaven + } + if pkg.Version == "" { + return ErrMissingVersionForMaven + } + + groupID, artifactID, err := parseMavenIdentifier(pkg.Identifier) + if err != nil { + return err + } + + pom, err := fetchMavenPOM(ctx, baseURL, groupID, artifactID, pkg.Version) + if err != nil { + return err + } + + // Sanity-check that the POM matches the declared coordinates. This catches + // publishing mistakes and proxy/mirror misconfiguration. + if pom.GroupID != "" && pom.GroupID != groupID { + return fmt.Errorf("maven POM groupId '%s' does not match identifier groupId '%s'", pom.GroupID, groupID) + } + if pom.ArtifactID != "" && pom.ArtifactID != artifactID { + return fmt.Errorf("maven POM artifactId '%s' does not match identifier artifactId '%s'", pom.ArtifactID, artifactID) + } + + // Preferred: explicit `` property in the POM. + if pom.Properties.MCPName != "" { + if pom.Properties.MCPName != serverName { + return fmt.Errorf("maven package ownership validation failed. Expected mcpName '%s', got '%s'", serverName, pom.Properties.MCPName) + } + return nil + } + + // Fallback: `mcp-name: ` in the POM description (mirrors the PyPI/NuGet pattern). + mcpNamePattern := "mcp-name: " + serverName + if strings.Contains(pom.Description, mcpNamePattern) { + return nil + } + + return fmt.Errorf( + "maven package '%s:%s' ownership validation failed. Add either an `%s` property to the published POM or an 'mcp-name: %s' line to the POM ``", + groupID, artifactID, serverName, serverName, + ) +} + +// parseMavenIdentifier splits a "groupId:artifactId" coordinate. Both parts +// must be non-empty; we deliberately reject the three-part "g:a:v" form so +// version always lives in the dedicated Version field (consistent with how +// other JVM-aware MCP clients consume coordinates). +func parseMavenIdentifier(identifier string) (groupID, artifactID string, err error) { + parts := strings.Split(identifier, ":") + if len(parts) != 2 { + return "", "", ErrInvalidMavenIdentifier + } + groupID, artifactID = parts[0], parts[1] + if groupID == "" || artifactID == "" { + return "", "", ErrInvalidMavenIdentifier + } + return groupID, artifactID, nil +} + +// fetchMavenPOM downloads and parses the published POM for the coordinate. +func fetchMavenPOM(ctx context.Context, baseURL, groupID, artifactID, version string) (*mavenPOM, error) { + pomURL := buildPOMURL(baseURL, groupID, artifactID, version) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pomURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create Maven POM request: %w", err) + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/xml") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch Maven POM: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("maven package '%s:%s' version '%s' not found at %s", groupID, artifactID, version, pomURL) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("maven POM request returned status %d", resp.StatusCode) + } + + // Cap the body so a hostile mirror can't exhaust memory; published POMs + // are typically only a few KB. + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read Maven POM: %w", err) + } + + var pom mavenPOM + if err := xml.Unmarshal(body, &pom); err != nil { + return nil, fmt.Errorf("failed to parse Maven POM: %w", err) + } + return &pom, nil +} + +// buildPOMURL constructs the canonical POM URL on the Maven repository +// layout: groupId dots become path slashes. +func buildPOMURL(baseURL, groupID, artifactID, version string) string { + groupPath := strings.ReplaceAll(groupID, ".", "/") + // PathEscape each segment to keep e.g. unusual artifact ids safe. + return strings.TrimRight(baseURL, "/") + "/" + + groupPath + "/" + + url.PathEscape(artifactID) + "/" + + url.PathEscape(version) + "/" + + url.PathEscape(artifactID) + "-" + url.PathEscape(version) + ".pom" +} diff --git a/internal/validators/registries/maven_export_test.go b/internal/validators/registries/maven_export_test.go new file mode 100644 index 000000000..132414ef0 --- /dev/null +++ b/internal/validators/registries/maven_export_test.go @@ -0,0 +1,17 @@ +package registries + +import ( + "context" + + "github.com/modelcontextprotocol/registry/pkg/model" +) + +// ValidateMavenAt is a test-only entry point that exposes the Maven validator +// against an arbitrary base URL (so tests can use httptest servers without +// going through the Maven Central allowlist check). +// +// This file ends in _test.go so the symbol is excluded from production builds +// while remaining accessible to the registries_test package. +func ValidateMavenAt(ctx context.Context, baseURL string, pkg model.Package, serverName string) error { + return validateMavenAgainst(ctx, baseURL, pkg, serverName) +} diff --git a/internal/validators/registries/maven_test.go b/internal/validators/registries/maven_test.go new file mode 100644 index 000000000..291140b24 --- /dev/null +++ b/internal/validators/registries/maven_test.go @@ -0,0 +1,239 @@ +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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pomTemplate produces a minimal valid POM with the requested fields. Empty +// values omit the corresponding element so we can test "missing field" branches. +// +//nolint:unparam // artifactID is intentionally configurable to keep this helper general for future tests. +func pomTemplate(groupID, artifactID, mcpName, description string) string { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + b.WriteString(` 4.0.0` + "\n") + if groupID != "" { + b.WriteString(" " + groupID + "\n") + } + if artifactID != "" { + b.WriteString(" " + artifactID + "\n") + } + b.WriteString(" 1.0.0\n") + if description != "" { + b.WriteString(" " + description + "\n") + } + if mcpName != "" { + b.WriteString(" \n") + b.WriteString(" " + mcpName + "\n") + b.WriteString(" \n") + } + b.WriteString(`` + "\n") + return b.String() +} + +// startMavenMock returns an httptest server that serves the test-provided POM +// body at the expected canonical Maven layout path; any other path returns 404. +// +//nolint:unparam // status is intentionally configurable for future non-200 test cases. +func startMavenMock(t *testing.T, expectedPath, body string, status int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != expectedPath { + http.NotFound(w, r) + return + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) +} + +func TestValidateMaven_MissingOrInvalidFields(t *testing.T) { + ctx := context.Background() + + cases := []struct { + name string + pkg model.Package + wantMsg string + }{ + { + name: "missing identifier", + pkg: model.Package{RegistryType: model.RegistryTypeMaven, Version: "1.0.0"}, + wantMsg: "package identifier is required for Maven packages", + }, + { + name: "missing version", + pkg: model.Package{RegistryType: model.RegistryTypeMaven, Identifier: "org.example:foo"}, + wantMsg: "package version is required for Maven packages", + }, + { + name: "fileSha256 not allowed", + pkg: model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: "org.example:foo", + Version: "1.0.0", + FileSHA256: "abc", + }, + wantMsg: "must not have 'fileSha256' field", + }, + { + name: "wrong identifier shape", + pkg: model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: "org.example.foo", + Version: "1.0.0", + }, + wantMsg: "groupId:artifactId", + }, + { + name: "empty group in identifier", + pkg: model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: ":foo", + Version: "1.0.0", + }, + wantMsg: "groupId:artifactId", + }, + { + name: "non-Maven base URL rejected", + pkg: model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: "org.example:foo", + Version: "1.0.0", + RegistryBaseURL: "https://example.com/repo", + }, + wantMsg: "registry type and base URL do not match", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := registries.ValidateMaven(ctx, tc.pkg, "com.example/server") + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantMsg) + }) + } +} + +// TestValidateMaven_OwnershipAgainstMockRepo exercises the parsing + ownership +// branches by pointing the validator at an httptest server via the test-only +// ValidateMavenAt helper (the production entry point is restricted to the +// official Maven Central host). +func TestValidateMaven_OwnershipAgainstMockRepo(t *testing.T) { + const ( + groupID = "io.github.example" + artifactID = "demo-mcp-server" + version = "1.2.3" + serverName = "io.github.example/demo-mcp-server" + ) + expectedPath := "/io/github/example/" + artifactID + "/" + version + "/" + artifactID + "-" + version + ".pom" + + t.Run("matching mcpName property passes", func(t *testing.T) { + body := pomTemplate(groupID, artifactID, serverName, "") + srv := startMavenMock(t, expectedPath, body, http.StatusOK) + defer srv.Close() + + err := registries.ValidateMavenAt(context.Background(), srv.URL, model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: groupID + ":" + artifactID, + Version: version, + }, serverName) + assert.NoError(t, err) + }) + + t.Run("description fallback passes", func(t *testing.T) { + body := pomTemplate(groupID, artifactID, "", "Sample server\nmcp-name: "+serverName+"\nmore notes") + srv := startMavenMock(t, expectedPath, body, http.StatusOK) + defer srv.Close() + + err := registries.ValidateMavenAt(context.Background(), srv.URL, model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: groupID + ":" + artifactID, + Version: version, + }, serverName) + assert.NoError(t, err) + }) + + t.Run("mismatched mcpName fails", func(t *testing.T) { + body := pomTemplate(groupID, artifactID, "io.github.other/wrong", "") + srv := startMavenMock(t, expectedPath, body, http.StatusOK) + defer srv.Close() + + err := registries.ValidateMavenAt(context.Background(), srv.URL, model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: groupID + ":" + artifactID, + Version: version, + }, serverName) + require.Error(t, err) + assert.Contains(t, err.Error(), "Expected mcpName") + }) + + t.Run("missing ownership signal fails with helpful message", func(t *testing.T) { + body := pomTemplate(groupID, artifactID, "", "Just a description") + srv := startMavenMock(t, expectedPath, body, http.StatusOK) + defer srv.Close() + + err := registries.ValidateMavenAt(context.Background(), srv.URL, model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: groupID + ":" + artifactID, + Version: version, + }, serverName) + require.Error(t, err) + assert.Contains(t, err.Error(), "ownership validation failed") + // Error should tell publishers exactly what to add. + assert.Contains(t, err.Error(), "") + assert.Contains(t, err.Error(), "mcp-name: "+serverName) + }) + + t.Run("groupId mismatch in POM fails", func(t *testing.T) { + body := pomTemplate("io.github.different", artifactID, serverName, "") + srv := startMavenMock(t, expectedPath, body, http.StatusOK) + defer srv.Close() + + err := registries.ValidateMavenAt(context.Background(), srv.URL, model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: groupID + ":" + artifactID, + Version: version, + }, serverName) + require.Error(t, err) + assert.Contains(t, err.Error(), "POM groupId") + }) + + t.Run("missing artifact returns 404 error", func(t *testing.T) { + // Serve 404 for any path. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + err := registries.ValidateMavenAt(context.Background(), srv.URL, model.Package{ + RegistryType: model.RegistryTypeMaven, + Identifier: groupID + ":" + artifactID, + Version: version, + }, serverName) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("malformed XML fails with parse error", func(t *testing.T) { + srv := startMavenMock(t, expectedPath, "=1.2.3', '1.x', '1.*')." example:"1.0.2"` // FileSHA256 is the SHA-256 hash for integrity verification (required for mcpb, optional for others) FileSHA256 string `json:"fileSha256,omitempty" pattern:"^[a-f0-9]{64}$" doc:"SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity." example:"fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce"` diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index 2f8be56ff..4c48ae649 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -33,7 +33,7 @@ func main() { func runValidation() error { // Define what we validate and how - expectedServerJSONCount := 16 + expectedServerJSONCount := 17 targets := []validationTarget{ { path: filepath.Join("docs", "reference", "server-json", "generic-server-json.md"),