Skip to content

Commit e0315e4

Browse files
committed
feat(sources): add Maven Central package source for JVM MCP servers
Add a `maven` registry type so JVM-based MCP servers (Java/Kotlin/Scala) can be published to Maven Central and resolved through the registry. Ownership is verified by reading the published POM and matching either an `<mcpName>` Maven property or an `mcp-name: <serverName>` line in the POM `<description>`. 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. The identifier is the Maven `groupId:artifactId` coordinate (e.g., `org.example:my-mcp-server`); version stays in the dedicated `version` field so it's consistent with the other registry types. Closes #1102
1 parent 7722031 commit e0315e4

13 files changed

Lines changed: 526 additions & 14 deletions

File tree

docs/reference/api/openapi.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,13 +659,14 @@ components:
659659
properties:
660660
registryType:
661661
type: string
662-
description: Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')
662+
description: Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb', 'maven')
663663
examples:
664664
- "npm"
665665
- "pypi"
666666
- "oci"
667667
- "nuget"
668668
- "mcpb"
669+
- "maven"
669670
registryBaseUrl:
670671
type: string
671672
format: uri
@@ -675,6 +676,7 @@ components:
675676
- "https://pypi.org"
676677
- "https://docker.io"
677678
- "https://api.nuget.org/v3/index.json"
679+
- "https://repo.maven.apache.org/maven2"
678680
- "https://github.com"
679681
- "https://gitlab.com"
680682
identifier:
@@ -698,7 +700,7 @@ components:
698700
runtimeHint:
699701
type: string
700702
description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.
701-
examples: [npx, uvx, docker, dnx]
703+
examples: [npx, uvx, docker, dnx, jbang]
702704
transport:
703705
$ref: '#/components/schemas/LocalTransport'
704706
description: Transport protocol configuration for the package

docs/reference/server-json/draft/server.schema.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,20 +241,22 @@
241241
"https://pypi.org",
242242
"https://docker.io",
243243
"https://api.nuget.org/v3/index.json",
244+
"https://repo.maven.apache.org/maven2",
244245
"https://github.com",
245246
"https://gitlab.com"
246247
],
247248
"format": "uri",
248249
"type": "string"
249250
},
250251
"registryType": {
251-
"description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb')",
252+
"description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'oci', 'nuget', 'mcpb', 'maven')",
252253
"examples": [
253254
"npm",
254255
"pypi",
255256
"oci",
256257
"nuget",
257-
"mcpb"
258+
"mcpb",
259+
"maven"
258260
],
259261
"type": "string"
260262
},
@@ -271,7 +273,8 @@
271273
"npx",
272274
"uvx",
273275
"docker",
274-
"dnx"
276+
"dnx",
277+
"jbang"
275278
],
276279
"type": "string"
277280
},

docs/reference/server-json/generic-server-json.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,49 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
423423
}
424424
```
425425

426+
### Maven (JVM) Package Example
427+
428+
JVM-based MCP servers can be published to Maven Central. The `jbang` runtime
429+
hint matches the typical `jbang org.example:my-mcp-server:1.0.0` invocation.
430+
431+
```json
432+
{
433+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
434+
"name": "io.github.example/demo-mcp-server",
435+
"description": "Sample JVM MCP server published to Maven Central",
436+
"repository": {
437+
"url": "https://github.com/example/demo-mcp-server",
438+
"source": "github",
439+
"id": "example-maven-id-0000-1111-222222222222"
440+
},
441+
"version": "1.0.0",
442+
"packages": [
443+
{
444+
"registryType": "maven",
445+
"registryBaseUrl": "https://repo.maven.apache.org/maven2",
446+
"identifier": "io.github.example:demo-mcp-server",
447+
"version": "1.0.0",
448+
"runtimeHint": "jbang",
449+
"transport": {
450+
"type": "stdio"
451+
}
452+
}
453+
],
454+
"_meta": {
455+
"io.modelcontextprotocol.registry/publisher-provided": {
456+
"tool": "maven-publisher",
457+
"version": "1.0.0"
458+
}
459+
}
460+
}
461+
```
462+
463+
Ownership is verified by reading the published POM at
464+
`https://repo.maven.apache.org/maven2/<group/path>/<artifactId>/<version>/<artifactId>-<version>.pom`.
465+
Add either an `<mcpName>...</mcpName>` property or an `mcp-name: <serverName>`
466+
line in the POM `<description>` so the registry can match the package to the
467+
server name.
468+
426469
### Complex Docker Server with Multiple Arguments
427470

428471
```json

docs/reference/server-json/official-registry-requirements.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Only trusted public registries are supported. Private registries and alternative
3333
- **NPM**: `https://registry.npmjs.org` only
3434
- **PyPI**: `https://pypi.org` only
3535
- **NuGet**: `https://api.nuget.org/v3/index.json` only
36+
- **Maven**: `https://repo.maven.apache.org/maven2` only (Maven Central)
3637
- **Docker/OCI**:
3738
- Docker Hub (`docker.io`)
3839
- GitHub Container Registry (`ghcr.io`)

internal/validators/package.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ func ValidatePackage(ctx context.Context, pkg model.Package, serverName string)
2323
return registries.ValidateOCI(ctx, pkg, serverName)
2424
case model.RegistryTypeMCPB:
2525
return registries.ValidateMCPB(ctx, pkg, serverName)
26+
case model.RegistryTypeMaven:
27+
return registries.ValidateMaven(ctx, pkg, serverName)
2628
default:
2729
return fmt.Errorf("unsupported registry type: %s", pkg.RegistryType)
2830
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package registries
2+
3+
import (
4+
"context"
5+
"encoding/xml"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
"github.com/modelcontextprotocol/registry/pkg/model"
15+
)
16+
17+
var (
18+
ErrMissingIdentifierForMaven = errors.New("package identifier is required for Maven packages")
19+
ErrMissingVersionForMaven = errors.New("package version is required for Maven packages")
20+
ErrInvalidMavenIdentifier = errors.New("maven package identifier must be in 'groupId:artifactId' form (e.g., 'org.example:my-mcp-server')")
21+
)
22+
23+
// mavenPOM represents the subset of Maven POM XML fields used to validate
24+
// MCP server ownership. Only the fields we read are declared.
25+
type mavenPOM struct {
26+
XMLName xml.Name `xml:"project"`
27+
GroupID string `xml:"groupId"`
28+
ArtifactID string `xml:"artifactId"`
29+
Description string `xml:"description"`
30+
Properties struct {
31+
// Maven properties is a free-form element; we look for an
32+
// `mcpName` property as the most explicit ownership signal.
33+
MCPName string `xml:"mcpName"`
34+
} `xml:"properties"`
35+
// Parent POM may set groupId for the project; we do not currently
36+
// follow parent inheritance because the published POM should declare
37+
// its own groupId for ownership purposes.
38+
}
39+
40+
// ValidateMaven validates that a Maven Central package contains the correct
41+
// MCP server name. Identifier is "groupId:artifactId" (e.g., "org.example:my-mcp-server")
42+
// and Version must be a concrete release (no version ranges).
43+
//
44+
// Ownership is verified by fetching the published POM at:
45+
//
46+
// {baseURL}/{groupPath}/{artifactId}/{version}/{artifactId}-{version}.pom
47+
//
48+
// and looking for the server name as either:
49+
// 1. an `<mcpName>` Maven property, or
50+
// 2. an `mcp-name: <serverName>` line in the POM `<description>`.
51+
//
52+
// Maven Central already validates `groupId` domain ownership at publish time,
53+
// so a matching declaration in the POM is sufficient to bind a package to an
54+
// MCP server name in the same namespace.
55+
func ValidateMaven(ctx context.Context, pkg model.Package, serverName string) error {
56+
if pkg.RegistryBaseURL == "" {
57+
pkg.RegistryBaseURL = model.RegistryURLMaven
58+
}
59+
60+
if pkg.Identifier == "" {
61+
return ErrMissingIdentifierForMaven
62+
}
63+
if pkg.Version == "" {
64+
return ErrMissingVersionForMaven
65+
}
66+
67+
// Maven packages must not carry MCPB-only fields.
68+
if pkg.FileSHA256 != "" {
69+
return fmt.Errorf("maven packages must not have 'fileSha256' field - this is only for MCPB packages")
70+
}
71+
72+
// Maven Central is the only allowed base URL for the official registry.
73+
if pkg.RegistryBaseURL != model.RegistryURLMaven {
74+
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
75+
pkg.RegistryBaseURL, model.RegistryTypeMaven, model.RegistryURLMaven)
76+
}
77+
78+
return validateMavenAgainst(ctx, pkg.RegistryBaseURL, pkg, serverName)
79+
}
80+
81+
// validateMavenAgainst is the common validation routine, parameterized on
82+
// the base URL so tests can point it at an httptest server without going
83+
// through the Maven Central allowlist check.
84+
func validateMavenAgainst(ctx context.Context, baseURL string, pkg model.Package, serverName string) error {
85+
if pkg.Identifier == "" {
86+
return ErrMissingIdentifierForMaven
87+
}
88+
if pkg.Version == "" {
89+
return ErrMissingVersionForMaven
90+
}
91+
92+
groupID, artifactID, err := parseMavenIdentifier(pkg.Identifier)
93+
if err != nil {
94+
return err
95+
}
96+
97+
pom, err := fetchMavenPOM(ctx, baseURL, groupID, artifactID, pkg.Version)
98+
if err != nil {
99+
return err
100+
}
101+
102+
// Sanity-check that the POM matches the declared coordinates. This catches
103+
// publishing mistakes and proxy/mirror misconfiguration.
104+
if pom.GroupID != "" && pom.GroupID != groupID {
105+
return fmt.Errorf("maven POM groupId '%s' does not match identifier groupId '%s'", pom.GroupID, groupID)
106+
}
107+
if pom.ArtifactID != "" && pom.ArtifactID != artifactID {
108+
return fmt.Errorf("maven POM artifactId '%s' does not match identifier artifactId '%s'", pom.ArtifactID, artifactID)
109+
}
110+
111+
// Preferred: explicit `<mcpName>` property in the POM.
112+
if pom.Properties.MCPName != "" {
113+
if pom.Properties.MCPName != serverName {
114+
return fmt.Errorf("maven package ownership validation failed. Expected mcpName '%s', got '%s'", serverName, pom.Properties.MCPName)
115+
}
116+
return nil
117+
}
118+
119+
// Fallback: `mcp-name: <serverName>` in the POM description (mirrors the PyPI/NuGet pattern).
120+
mcpNamePattern := "mcp-name: " + serverName
121+
if strings.Contains(pom.Description, mcpNamePattern) {
122+
return nil
123+
}
124+
125+
return fmt.Errorf(
126+
"maven package '%s:%s' ownership validation failed. Add either an `<mcpName>%s</mcpName>` property to the published POM or an 'mcp-name: %s' line to the POM `<description>`",
127+
groupID, artifactID, serverName, serverName,
128+
)
129+
}
130+
131+
// parseMavenIdentifier splits a "groupId:artifactId" coordinate. Both parts
132+
// must be non-empty; we deliberately reject the three-part "g:a:v" form so
133+
// version always lives in the dedicated Version field (consistent with how
134+
// other JVM-aware MCP clients consume coordinates).
135+
func parseMavenIdentifier(identifier string) (groupID, artifactID string, err error) {
136+
parts := strings.Split(identifier, ":")
137+
if len(parts) != 2 {
138+
return "", "", ErrInvalidMavenIdentifier
139+
}
140+
groupID, artifactID = parts[0], parts[1]
141+
if groupID == "" || artifactID == "" {
142+
return "", "", ErrInvalidMavenIdentifier
143+
}
144+
return groupID, artifactID, nil
145+
}
146+
147+
// fetchMavenPOM downloads and parses the published POM for the coordinate.
148+
func fetchMavenPOM(ctx context.Context, baseURL, groupID, artifactID, version string) (*mavenPOM, error) {
149+
pomURL := buildPOMURL(baseURL, groupID, artifactID, version)
150+
151+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pomURL, nil)
152+
if err != nil {
153+
return nil, fmt.Errorf("failed to create Maven POM request: %w", err)
154+
}
155+
req.Header.Set("User-Agent", userAgent)
156+
req.Header.Set("Accept", "application/xml")
157+
158+
client := &http.Client{Timeout: 10 * time.Second}
159+
resp, err := client.Do(req)
160+
if err != nil {
161+
return nil, fmt.Errorf("failed to fetch Maven POM: %w", err)
162+
}
163+
defer resp.Body.Close()
164+
165+
if resp.StatusCode == http.StatusNotFound {
166+
return nil, fmt.Errorf("maven package '%s:%s' version '%s' not found at %s", groupID, artifactID, version, pomURL)
167+
}
168+
if resp.StatusCode != http.StatusOK {
169+
return nil, fmt.Errorf("maven POM request returned status %d", resp.StatusCode)
170+
}
171+
172+
// Cap the body so a hostile mirror can't exhaust memory; published POMs
173+
// are typically only a few KB.
174+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
175+
if err != nil {
176+
return nil, fmt.Errorf("failed to read Maven POM: %w", err)
177+
}
178+
179+
var pom mavenPOM
180+
if err := xml.Unmarshal(body, &pom); err != nil {
181+
return nil, fmt.Errorf("failed to parse Maven POM: %w", err)
182+
}
183+
return &pom, nil
184+
}
185+
186+
// buildPOMURL constructs the canonical POM URL on the Maven repository
187+
// layout: groupId dots become path slashes.
188+
func buildPOMURL(baseURL, groupID, artifactID, version string) string {
189+
groupPath := strings.ReplaceAll(groupID, ".", "/")
190+
// PathEscape each segment to keep e.g. unusual artifact ids safe.
191+
return strings.TrimRight(baseURL, "/") + "/" +
192+
groupPath + "/" +
193+
url.PathEscape(artifactID) + "/" +
194+
url.PathEscape(version) + "/" +
195+
url.PathEscape(artifactID) + "-" + url.PathEscape(version) + ".pom"
196+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package registries
2+
3+
import (
4+
"context"
5+
6+
"github.com/modelcontextprotocol/registry/pkg/model"
7+
)
8+
9+
// ValidateMavenAt is a test-only entry point that exposes the Maven validator
10+
// against an arbitrary base URL (so tests can use httptest servers without
11+
// going through the Maven Central allowlist check).
12+
//
13+
// This file ends in _test.go so the symbol is excluded from production builds
14+
// while remaining accessible to the registries_test package.
15+
func ValidateMavenAt(ctx context.Context, baseURL string, pkg model.Package, serverName string) error {
16+
return validateMavenAgainst(ctx, baseURL, pkg, serverName)
17+
}

0 commit comments

Comments
 (0)