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
51 changes: 51 additions & 0 deletions docs/modelcontextprotocol-io/package-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,57 @@ This MCP server manages Azure DevOps work items and pipelines.
<!-- mcp-name: io.github.username/azure-devops-mcp -->
```

## Cargo (Rust) Packages

For Cargo packages, the MCP Registry currently supports the official crates.io registry (`https://crates.io`) only.

Cargo packages use `"registryType": "cargo"` in `server.json`. For example:

```json server.json highlight={9}
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.username/widget-mcp",
"title": "Widget",
"description": "Rust-native MCP server",
"version": "0.3.0",
"packages": [
{
"registryType": "cargo",
"identifier": "widget-mcp",
"version": "0.3.0",
"transport": {
"type": "stdio"
}
}
]
}
```

### Runtime Model

Cargo's runtime model differs from npm/PyPI/NuGet. `cargo install <crate>` places the compiled binary on PATH at `~/.cargo/bin`, after which MCP clients invoke it directly by name. There is no per-invocation runner equivalent to `npx` (npm), `uvx` (PyPI), or `dnx` (NuGet, .NET 10 SDK Preview 6+) — install is one-time, execution is by binary name. The Cargo example above intentionally omits `runtimeHint` for this reason.

Rust MCP authors have two first-class distribution paths:

- **Cargo (`registryType: cargo`)** — source-distributed via crates.io. End users need the Rust toolchain (`rustup`) to run `cargo install`. Idiomatic for the Rust ecosystem and consistent with how Rust CLIs are typically published.
- **MCPB (`registryType: mcpb`)** — prebuilt binary distributed via GitHub or GitLab Releases. End users need no toolchain. Right choice if the priority is "no Rust toolchain required."

Both paths are supported; the choice is the author's. Cargo native support exists so Rust authors who prefer source distribution are not forced into the MCPB binary-packaging workaround.

### Ownership Verification

The MCP Registry verifies ownership of Cargo packages by checking for the existence of an `mcp-name: $SERVER_NAME` string in the package README (which is rendered to HTML and served by crates.io's static CDN). The `$SERVER_NAME` portion **MUST** match the server name from `server.json`. For example:

```markdown README.md highlight={5}
# Widget MCP Server

A Rust-native MCP server for widget operations.

- MCP Registry name: `mcp-name: io.github.username/widget-mcp`
```

**Cargo-specific gotcha:** Unlike PyPI and NuGet (which preserve HTML comments in their README rendering), **crates.io strips HTML comments during markdown→HTML conversion**. The `<!-- mcp-name: ... -->` hidden-comment form that works for PyPI/NuGet **does not work for cargo** — the token will not appear in the rendered HTML the validator inspects. Cargo authors must include the `mcp-name:` token as visible markdown text. A simple bullet in the Links section is the recommended pattern.

## Docker/OCI Images

For Docker/OCI images, the MCP Registry currently supports:
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,11 @@ 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', 'cargo', 'oci', 'nuget', 'mcpb')
examples:
- "npm"
- "pypi"
- "cargo"
- "oci"
- "nuget"
- "mcpb"
Expand All @@ -673,6 +674,7 @@ components:
examples:
- "https://registry.npmjs.org"
- "https://pypi.org"
- "https://crates.io"
- "https://docker.io"
- "https://api.nuget.org/v3/index.json"
- "https://github.com"
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/server-json/draft/server.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@
"examples": [
"https://registry.npmjs.org",
"https://pypi.org",
"https://crates.io",
"https://docker.io",
"https://api.nuget.org/v3/index.json",
"https://github.com",
Expand All @@ -248,10 +249,11 @@
"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', 'cargo', 'oci', 'nuget', 'mcpb')",
"examples": [
"npm",
"pypi",
"cargo",
"oci",
"nuget",
"mcpb"
Expand Down
29 changes: 29 additions & 0 deletions docs/reference/server-json/generic-server-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,35 @@ The same `registryType` / `identifier` pattern works for other supported OCI hos
}
```

### Cargo (Rust) Package Example

`cargo install <crate>` places the binary on PATH (via `~/.cargo/bin`); MCP clients invoke it directly by name. There is no single-shot equivalent of `npx` (npm), `uvx` (PyPI), or `dnx` (NuGet, .NET 10 SDK) for cargo — install once, run by name.

```json
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.example/widget-mcp",
"description": "Rust-native MCP server",
"title": "Widget",
"repository": {
"url": "https://github.com/example/widget-mcp",
"source": "github"
},
"version": "0.3.0",
"packages": [
{
"registryType": "cargo",
"registryBaseUrl": "https://crates.io",
"identifier": "widget-mcp",
"version": "0.3.0",
"transport": {
"type": "stdio"
}
}
]
}
```

### NuGet (.NET) Package Example

The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
Expand Down
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.RegistryTypeCargo:
return registries.ValidateCargo(ctx, pkg, serverName)
default:
return fmt.Errorf("unsupported registry type: %s", pkg.RegistryType)
}
Expand Down
160 changes: 160 additions & 0 deletions internal/validators/registries/cargo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package registries

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

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

var (
ErrMissingIdentifierForCargo = errors.New("package identifier is required for Cargo packages")
ErrMissingVersionForCargo = errors.New("package version is required for Cargo packages")
)

// CargoReadmeMetaResponse is the structure returned by the crates.io readme metadata endpoint.
//
// With `Accept: application/json`, crates.io's /api/v1/crates/{name}/{version}/readme
// endpoint returns 200 OK with a JSON body containing a `url` field that points to the
// rendered README on the static CDN. (Without the Accept header, or via HEAD, the same
// endpoint emits a 302 redirect to the CDN URL — the validator uses the JSON path so
// that crates.io controls where the README lives.) Validators must follow the pointer
// to retrieve the actual README content.
type CargoReadmeMetaResponse struct {
URL string `json:"url"`
}

// ValidateCargo validates that a Cargo (crates.io) package contains the correct MCP server name.
//
// Verification mechanism: the `mcp-name: <server-name>` token is searched for in the package's
// rendered README. This mirrors the PyPI validator's README-token approach (see ValidatePyPI),
// requiring no Cargo.toml parsing on the registry side. Crate authors add a single line
// `mcp-name: io.github.OWNER/REPO` to their README before publishing.
//
// Two-call retrieval pattern:
// 1. GET https://crates.io/api/v1/crates/{name}/{version}/readme
// → 200 OK with JSON: {"url": "https://static.crates.io/readmes/.../...html"}
// 2. GET <url from step 1>
// → 200 OK with rendered README HTML, or 403 if the crate/version is missing
//
// The two-call pattern stays on the documented crates.io API surface rather than relying
// on the CDN URL layout being stable.
func ValidateCargo(ctx context.Context, pkg model.Package, serverName string) error {
// Set default registry base URL if empty
if pkg.RegistryBaseURL == "" {
pkg.RegistryBaseURL = model.RegistryURLCrates
}

if pkg.Identifier == "" {
return ErrMissingIdentifierForCargo
}

if pkg.Version == "" {
return ErrMissingVersionForCargo
}

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

// Validate that the registry base URL matches crates.io exactly
if pkg.RegistryBaseURL != model.RegistryURLCrates {
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
pkg.RegistryBaseURL, model.RegistryTypeCargo, model.RegistryURLCrates)
}

return validateCargoREADME(ctx, pkg, serverName)
}

// validateCargoREADME performs the two-call README fetch and the mcp-name token
// check. It is split out from ValidateCargo so that httptest-based tests can
// drive the HTTP pipeline against a mock server (exposed via export_test.go),
// bypassing the exact-baseURL guard that ValidateCargo enforces for callers.
func validateCargoREADME(ctx context.Context, pkg model.Package, serverName string) error {
client := &http.Client{Timeout: 10 * time.Second}
// crates.io's crawler policy expects a non-generic User-Agent identifying the source.
userAgent := "MCP-Registry-Validator/1.0 (https://registry.modelcontextprotocol.io)"

// Step 1: fetch the README pointer from the documented API endpoint.
metaURL := fmt.Sprintf("%s/api/v1/crates/%s/%s/readme",
pkg.RegistryBaseURL,
url.PathEscape(pkg.Identifier),
url.PathEscape(pkg.Version))

metaReq, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil)
if err != nil {
return fmt.Errorf("failed to create crates.io metadata request: %w", err)
}
metaReq.Header.Set("User-Agent", userAgent)
metaReq.Header.Set("Accept", "application/json")

metaResp, err := client.Do(metaReq)
if err != nil {
return fmt.Errorf("failed to fetch package metadata from crates.io: %w", err)
}
defer metaResp.Body.Close()

if metaResp.StatusCode != http.StatusOK {
// 5xx from the metadata endpoint is upstream availability, not a missing crate.
if metaResp.StatusCode >= 500 && metaResp.StatusCode < 600 {
return fmt.Errorf("crates.io upstream error fetching metadata for cargo package '%s' (status: %d) — likely transient, retry later", pkg.Identifier, metaResp.StatusCode)
}
return fmt.Errorf("cargo package '%s' metadata fetch failed (status: %d)", pkg.Identifier, metaResp.StatusCode)
}

var meta CargoReadmeMetaResponse
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
return fmt.Errorf("failed to parse crates.io readme metadata: %w", err)
}
if meta.URL == "" {
return fmt.Errorf("cargo package '%s' metadata response missing 'url' field", pkg.Identifier)
}

// Step 2: fetch the rendered README from the URL the API gave us.
readmeReq, err := http.NewRequestWithContext(ctx, http.MethodGet, meta.URL, nil)
if err != nil {
return fmt.Errorf("failed to create crates.io readme request: %w", err)
}
readmeReq.Header.Set("User-Agent", userAgent)
readmeReq.Header.Set("Accept", "text/html")

readmeResp, err := client.Do(readmeReq)
if err != nil {
return fmt.Errorf("failed to fetch rendered README from crates.io: %w", err)
}
defer readmeResp.Body.Close()

// Missing crates and missing versions surface as 403 from static.crates.io
// (S3's default for missing keys), not 404. 5xx from the CDN is upstream
// availability — surface it as transient so callers can distinguish retryable
// failures from genuinely missing crates.
if readmeResp.StatusCode != http.StatusOK {
if readmeResp.StatusCode >= 500 && readmeResp.StatusCode < 600 {
return fmt.Errorf("crates.io upstream error fetching README for cargo package '%s' version '%s' (status: %d) — likely transient, retry later", pkg.Identifier, pkg.Version, readmeResp.StatusCode)
}
return fmt.Errorf("cargo package '%s' version '%s' not found on crates.io (status: %d)", pkg.Identifier, pkg.Version, readmeResp.StatusCode)
}

body, err := io.ReadAll(readmeResp.Body)
if err != nil {
return fmt.Errorf("failed to read rendered README: %w", err)
}

// Search for the mcp-name: <server-name> token. The token contains no characters
// that get HTML-escaped during README rendering (no <, >, &, ", '), so a direct
// substring match against the rendered HTML is reliable.
mcpNamePattern := "mcp-name: " + serverName
if strings.Contains(string(body), mcpNamePattern) {
return nil
}

return fmt.Errorf("cargo package '%s' ownership validation failed. The server name '%s' must appear as 'mcp-name: %s' in the package README", pkg.Identifier, serverName, serverName)
}
Loading
Loading