feat: Add support for MCP Bundles (MCPB) in registry (#295)
## Motivation and Context - Add MCPB as a new registry_name option - Add file_hashes field to package model for integrity verification - Implement URL validation restricting MCPB packages to GitHub/GitLab hosts - Require SHA-256 hash for all MCPB packages - Add example MCPB package in documentation - Update schemas and OpenAPI specification See issue #260 ## How Has This Been Tested? <!-- Have you tested this in a real application? Which scenarios were tested? --> ## Breaking Changes N/A, registry has not yet been launched ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [ ] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [x] I have added or updated documentation as needed --------- Co-authored-by: Adam Jones <adamj+git@anthropic.com>
This commit is contained in:
parent
c3b72ef3a6
commit
57efb58143
19 changed files with 2014 additions and 2040 deletions
2798
data/seed.json
2798
data/seed.json
File diff suppressed because it is too large
Load diff
|
|
@ -20,8 +20,9 @@ _These examples show the PublishRequest format used by the `/v0/publish` API end
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "npm",
|
||||
"name": "@modelcontextprotocol/server-brave-search",
|
||||
"registry_type": "npm",
|
||||
"registry_base_url": "https://registry.npmjs.org",
|
||||
"identifier": "@modelcontextprotocol/server-brave-search",
|
||||
"version": "1.0.2",
|
||||
"environment_variables": [
|
||||
{
|
||||
|
|
@ -58,8 +59,9 @@ Suppose your MCP server application requires a `mcp start` CLI arguments to star
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "nuget",
|
||||
"name": "Knapcode.SampleMcpServer",
|
||||
"registry_type": "nuget",
|
||||
"registry_base_url": "https://api.nuget.org",
|
||||
"identifier": "Knapcode.SampleMcpServer",
|
||||
"version": "0.4.0-beta",
|
||||
"package_arguments": [
|
||||
{
|
||||
|
|
@ -106,8 +108,9 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "npm",
|
||||
"name": "@modelcontextprotocol/server-filesystem",
|
||||
"registry_type": "npm",
|
||||
"registry_base_url": "https://registry.npmjs.org",
|
||||
"identifier": "@modelcontextprotocol/server-filesystem",
|
||||
"version": "1.0.2",
|
||||
"package_arguments": [
|
||||
{
|
||||
|
|
@ -128,8 +131,9 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
|||
]
|
||||
},
|
||||
{
|
||||
"registry_name": "docker",
|
||||
"name": "mcp/filesystem",
|
||||
"registry_type": "docker-hub",
|
||||
"registry_base_url": "https://docker.io",
|
||||
"identifier": "mcp/filesystem:1.0.2",
|
||||
"version": "1.0.2",
|
||||
"runtime_arguments": [
|
||||
{
|
||||
|
|
@ -168,7 +172,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
|||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"x-publisher": {
|
||||
"tool": "ci-publisher",
|
||||
|
|
@ -235,8 +239,9 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "pypi",
|
||||
"name": "weather-mcp-server",
|
||||
"registry_type": "pypi",
|
||||
"registry_base_url": "https://pypi.org",
|
||||
"identifier": "weather-mcp-server",
|
||||
"version": "0.5.0",
|
||||
"runtime_hint": "uvx",
|
||||
"environment_variables": [
|
||||
|
|
@ -252,7 +257,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
|||
"default": "celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"x-publisher": {
|
||||
|
|
@ -287,8 +292,9 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "nuget",
|
||||
"name": "Knapcode.SampleMcpServer",
|
||||
"registry_type": "nuget",
|
||||
"registry_base_url": "https://api.nuget.org",
|
||||
"identifier": "Knapcode.SampleMcpServer",
|
||||
"version": "0.5.0",
|
||||
"runtime_hint": "dnx",
|
||||
"environment_variables": [
|
||||
|
|
@ -299,7 +305,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
"is_secret": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"x-publisher": {
|
||||
|
|
@ -333,8 +339,9 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "docker",
|
||||
"name": "example/database-manager-mcp",
|
||||
"registry_type": "docker-hub",
|
||||
"registry_base_url": "https://docker.io",
|
||||
"identifier": "example/database-manager-mcp",
|
||||
"version": "3.1.0",
|
||||
"runtime_arguments": [
|
||||
{
|
||||
|
|
@ -433,20 +440,21 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "npm",
|
||||
"name": "@example/hybrid-mcp-server",
|
||||
"registry_type": "npm",
|
||||
"registry_base_url": "https://registry.npmjs.org",
|
||||
"identifier": "@example/hybrid-mcp-server",
|
||||
"version": "1.5.0",
|
||||
"runtime_hint": "npx",
|
||||
"package_arguments": [
|
||||
{
|
||||
"type": "named",
|
||||
"name": "--mode",
|
||||
"description": "Operation mode",
|
||||
"default": "local",
|
||||
"choices": ["local", "cached", "proxy"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"package_arguments": [
|
||||
{
|
||||
"type": "named",
|
||||
"name": "--mode",
|
||||
"description": "Operation mode",
|
||||
"default": "local",
|
||||
"choices": ["local", "cached", "proxy"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"remotes": [
|
||||
{
|
||||
|
|
@ -490,6 +498,46 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
}
|
||||
```
|
||||
|
||||
## MCP Bundle (MCPB) Package Example
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"name": "io.modelcontextprotocol/text-editor",
|
||||
"description": "MCP Bundle server for advanced text editing capabilities",
|
||||
"repository": {
|
||||
"url": "https://github.com/modelcontextprotocol/text-editor-mcpb",
|
||||
"source": "github"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "1.0.2"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_type": "mcpb",
|
||||
"registry_base_url": "https://github.com",
|
||||
"identifier": "https://github.com/modelcontextprotocol/text-editor-mcpb/releases/download/v1.0.2/text-editor.mcpb",
|
||||
"version": "1.0.2",
|
||||
"file_sha256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce"
|
||||
}
|
||||
]
|
||||
},
|
||||
"x-publisher": {
|
||||
"tool": "mcpb-publisher",
|
||||
"version": "1.0.0",
|
||||
"build_info": {
|
||||
"timestamp": "2023-12-02T09:15:00Z",
|
||||
"bundle_format": "mcpb-v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This example shows an MCPB (MCP Bundle) package that:
|
||||
- Is hosted on GitHub Releases (an allowlisted provider)
|
||||
- Includes a SHA-256 hash for integrity verification
|
||||
- Can be downloaded and executed directly by MCP clients that support MCPB
|
||||
|
||||
## Deprecated Server Example
|
||||
|
||||
```json
|
||||
|
|
@ -508,8 +556,9 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "npm",
|
||||
"name": "@legacy/old-weather-server",
|
||||
"registry_type": "npm",
|
||||
"registry_base_url": "https://registry.npmjs.org",
|
||||
"identifier": "@legacy/old-weather-server",
|
||||
"version": "0.9.5",
|
||||
"environment_variables": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,12 +17,23 @@
|
|||
"packages": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"registry_name": {
|
||||
"registry_type": {
|
||||
"enum": [
|
||||
"npm",
|
||||
"pypi",
|
||||
"docker",
|
||||
"nuget"
|
||||
"pypi",
|
||||
"docker-hub",
|
||||
"nuget",
|
||||
"mcpb"
|
||||
]
|
||||
},
|
||||
"registry_base_url": {
|
||||
"enum": [
|
||||
"https://registry.npmjs.org",
|
||||
"https://pypi.org",
|
||||
"https://docker.io",
|
||||
"https://api.nuget.org",
|
||||
"https://github.com",
|
||||
"https://gitlab.com"
|
||||
]
|
||||
},
|
||||
"runtime_hint": {
|
||||
|
|
|
|||
|
|
@ -80,21 +80,22 @@
|
|||
},
|
||||
"Package": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"registry_name",
|
||||
"name",
|
||||
"version"
|
||||
],
|
||||
"properties": {
|
||||
"registry_name": {
|
||||
"registry_type": {
|
||||
"type": "string",
|
||||
"description": "Package registry type",
|
||||
"example": "npm"
|
||||
"description": "Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'docker-hub', 'nuget', 'mcpb')",
|
||||
"examples": ["npm", "pypi", "docker-hub", "nuget", "mcpb"]
|
||||
},
|
||||
"name": {
|
||||
"registry_base_url": {
|
||||
"type": "string",
|
||||
"description": "Package name in the registry",
|
||||
"example": "io.modelcontextprotocol/filesystem"
|
||||
"format": "uri",
|
||||
"description": "Base URL of the package registry",
|
||||
"examples": ["https://registry.npmjs.org", "https://pypi.org", "https://docker.io", "https://api.nuget.org", "https://github.com", "https://gitlab.com"]
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"description": "Package identifier - either a package name (for registries) or URL (for direct downloads)",
|
||||
"examples": ["@modelcontextprotocol/server-brave-search", "https://github.com/example/releases/download/v1.0.0/package.mcpb"]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
|
|
@ -102,6 +103,11 @@
|
|||
"example": "1.0.2",
|
||||
"minLength": 1
|
||||
},
|
||||
"file_sha256": {
|
||||
"type": "string",
|
||||
"description": "SHA-256 hash of the package file for integrity verification.",
|
||||
"example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce"
|
||||
},
|
||||
"runtime_hint": {
|
||||
"type": "string",
|
||||
"description": "A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtime_arguments` are present.",
|
||||
|
|
|
|||
|
|
@ -95,8 +95,9 @@ paths:
|
|||
version_detail:
|
||||
version: "1.0.2"
|
||||
packages:
|
||||
- registry_name: "npm"
|
||||
name: "@modelcontextprotocol/server-filesystem"
|
||||
- registry_type: "npm"
|
||||
registry_base_url: "https://registry.npmjs.org"
|
||||
identifier: "@modelcontextprotocol/server-filesystem"
|
||||
version: "1.0.2"
|
||||
x-publisher:
|
||||
tool: "publisher-cli"
|
||||
|
|
@ -117,8 +118,9 @@ paths:
|
|||
version_detail:
|
||||
version: "1.0.0"
|
||||
packages:
|
||||
- registry_name: "npm"
|
||||
name: "@example/mcp-demo-server"
|
||||
- registry_type: "npm"
|
||||
registry_base_url: "https://registry.npmjs.org"
|
||||
identifier: "@example/mcp-demo-server"
|
||||
version: "1.0.0"
|
||||
x-publisher:
|
||||
contact_email: "maintainer@example.com"
|
||||
|
|
@ -272,23 +274,41 @@ components:
|
|||
|
||||
Package:
|
||||
type: object
|
||||
required:
|
||||
- registry_name
|
||||
- name
|
||||
- version
|
||||
properties:
|
||||
registry_name:
|
||||
registry_type:
|
||||
type: string
|
||||
description: Package registry type
|
||||
example: "npm"
|
||||
name:
|
||||
description: Registry type indicating how to download packages (e.g., 'npm', 'pypi', 'docker-hub', 'nuget', 'mcpb')
|
||||
examples:
|
||||
- "npm"
|
||||
- "pypi"
|
||||
- "docker-hub"
|
||||
- "nuget"
|
||||
- "mcpb"
|
||||
registry_base_url:
|
||||
type: string
|
||||
description: Package name in the registry
|
||||
example: "io.modelcontextprotocol/filesystem"
|
||||
format: uri
|
||||
description: Base URL of the package registry
|
||||
examples:
|
||||
- "https://registry.npmjs.org"
|
||||
- "https://pypi.org"
|
||||
- "https://docker.io"
|
||||
- "https://api.nuget.org"
|
||||
- "https://github.com"
|
||||
- "https://gitlab.com"
|
||||
identifier:
|
||||
type: string
|
||||
description: Package identifier - either a package name (for registries) or URL (for direct downloads)
|
||||
examples:
|
||||
- "@modelcontextprotocol/server-brave-search"
|
||||
- "https://github.com/example/releases/download/v1.0.0/package.mcpb"
|
||||
version:
|
||||
type: string
|
||||
description: Package version
|
||||
example: "1.0.2"
|
||||
file_sha256:
|
||||
type: string
|
||||
description: SHA-256 hash of the package file for integrity verification.
|
||||
example: "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce"
|
||||
runtime_hint:
|
||||
type: string
|
||||
description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtime_arguments` are present.
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
|
|||
Server: model.ServerDetail{
|
||||
Name: "Test Server 1",
|
||||
Description: "First test server",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
Identifier: "test-package-1",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
XIOModelContextProtocolRegistry: map[string]interface{}{
|
||||
"id": "server-1",
|
||||
|
|
@ -109,6 +117,14 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
|
|||
Server: model.ServerDetail{
|
||||
Name: "Test Server 2",
|
||||
Description: "Second test server",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
Identifier: "test-package-2",
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
XIOModelContextProtocolRegistry: map[string]interface{}{
|
||||
"id": "server-2",
|
||||
|
|
@ -118,24 +134,26 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
|
|||
}
|
||||
|
||||
serverDetail1 := model.ServerDetail{
|
||||
Name: "Test Server 1",
|
||||
Name: "Test Server 1",
|
||||
Description: "First test server",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "test-package-1",
|
||||
Version: "1.0.0",
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
Identifier: "test-package-1",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
serverDetail2 := model.ServerDetail{
|
||||
Name: "Test Server 2",
|
||||
Name: "Test Server 2",
|
||||
Description: "Second test server",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "test-package-2",
|
||||
Version: "2.0.0",
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
Identifier: "test-package-2",
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -177,6 +195,12 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
|
|||
// No NextCursor means end of pagination
|
||||
},
|
||||
}
|
||||
default:
|
||||
// No more pages
|
||||
response = PaginatedResponse{
|
||||
Servers: []model.ServerResponse{},
|
||||
Metadata: &Metadata{},
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -212,6 +236,16 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
|
|||
assert.Equal(t, "Test Server 1", result[0].ServerJSON.Name)
|
||||
assert.Equal(t, "Test Server 2", result[1].ServerJSON.Name)
|
||||
|
||||
// Verify packages were imported with new schema
|
||||
assert.Len(t, result[0].ServerJSON.Packages, 1)
|
||||
assert.Equal(t, "npm", result[0].ServerJSON.Packages[0].RegistryType)
|
||||
assert.Equal(t, "https://registry.npmjs.org", result[0].ServerJSON.Packages[0].RegistryBaseURL)
|
||||
assert.Equal(t, "test-package-1", result[0].ServerJSON.Packages[0].Identifier)
|
||||
assert.Len(t, result[1].ServerJSON.Packages, 1)
|
||||
assert.Equal(t, "npm", result[1].ServerJSON.Packages[0].RegistryType)
|
||||
assert.Equal(t, "https://registry.npmjs.org", result[1].ServerJSON.Packages[0].RegistryBaseURL)
|
||||
assert.Equal(t, "test-package-2", result[1].ServerJSON.Packages[0].Identifier)
|
||||
|
||||
// Verify metadata was extracted
|
||||
assert.Equal(t, "server-1", result[0].RegistryMetadata.ID)
|
||||
assert.Equal(t, "server-2", result[1].RegistryMetadata.ID)
|
||||
|
|
|
|||
|
|
@ -88,13 +88,18 @@ type Argument struct {
|
|||
}
|
||||
|
||||
type Package struct {
|
||||
RegistryName string `json:"registry_name" bson:"registry_name"`
|
||||
Name string `json:"name" bson:"name"`
|
||||
Version string `json:"version" bson:"version"`
|
||||
RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"`
|
||||
RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"`
|
||||
PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"`
|
||||
EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"`
|
||||
// RegistryType indicates how to download packages (e.g., "npm", "pypi", "docker-hub", "mcpb")
|
||||
RegistryType string `json:"registry_type,omitempty" bson:"registry_type,omitempty"`
|
||||
// RegistryBaseURL is the base URL of the package registry
|
||||
RegistryBaseURL string `json:"registry_base_url,omitempty" bson:"registry_base_url,omitempty"`
|
||||
// Identifier is the package identifier - either a package name (for registries) or URL (for direct downloads)
|
||||
Identifier string `json:"identifier,omitempty" bson:"identifier,omitempty"`
|
||||
Version string `json:"version,omitempty" bson:"version,omitempty"`
|
||||
FileSHA256 string `json:"file_sha256,omitempty" bson:"file_sha256,omitempty"`
|
||||
RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"`
|
||||
RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"`
|
||||
PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"`
|
||||
EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"`
|
||||
}
|
||||
|
||||
// Remote represents a remote connection endpoint
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -68,6 +70,61 @@ func (s *registryServiceImpl) GetByID(id string) (*model.ServerResponse, error)
|
|||
return &response, nil
|
||||
}
|
||||
|
||||
// validateMCPBPackage validates MCPB packages
|
||||
func validateMCPBPackage(host string) error {
|
||||
allowedHosts := []string{
|
||||
"github.com",
|
||||
"www.github.com",
|
||||
"gitlab.com",
|
||||
"www.gitlab.com",
|
||||
}
|
||||
|
||||
isAllowed := false
|
||||
for _, allowed := range allowedHosts {
|
||||
if host == allowed {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isAllowed {
|
||||
return fmt.Errorf("MCPB packages must be hosted on allowlisted providers (GitHub or GitLab). Host '%s' is not allowed", host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePackage validates packages to ensure they meet requirements
|
||||
func validatePackage(pkg *model.Package) error {
|
||||
registryType := strings.ToLower(pkg.RegistryType)
|
||||
|
||||
// For direct download packages (mcpb or direct URLs)
|
||||
if registryType == "mcpb" ||
|
||||
strings.HasPrefix(pkg.Identifier, "http://") || strings.HasPrefix(pkg.Identifier, "https://") {
|
||||
parsedURL, err := url.Parse(pkg.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid package URL: %w", err)
|
||||
}
|
||||
|
||||
host := strings.ToLower(parsedURL.Host)
|
||||
|
||||
// For MCPB packages, validate they're from allowed hosts
|
||||
if registryType == "mcpb" || strings.HasSuffix(strings.ToLower(pkg.Identifier), ".mcpb") {
|
||||
return validateMCPBPackage(host)
|
||||
}
|
||||
|
||||
// For other URL-based packages, just ensure it's valid
|
||||
if parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||||
return fmt.Errorf("package URL must be a valid absolute URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For registry-based packages, no special validation needed
|
||||
// Registry types like "npm", "pypi", "docker-hub", "nuget" are all valid
|
||||
return nil
|
||||
}
|
||||
|
||||
// Publish publishes a server with separated extensions
|
||||
func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerResponse, error) {
|
||||
// Create a timeout context for the database operation
|
||||
|
|
@ -84,6 +141,13 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Validate all packages
|
||||
for _, pkg := range req.Server.Packages {
|
||||
if err := validatePackage(&pkg); err != nil {
|
||||
return nil, fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reverse-DNS namespace matching for remote URLs
|
||||
if err := model.ValidateRemoteNamespaceMatch(req.Server); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ type PackageValidator struct{}
|
|||
|
||||
// Validate checks if the package details are valid
|
||||
func (pv *PackageValidator) Validate(obj *model.Package) error {
|
||||
if !HasNoSpaces(obj.Name) {
|
||||
if !HasNoSpaces(obj.Identifier) {
|
||||
return ErrPackageNameHasSpaces
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ func TestObjectValidator_Validate(t *testing.T) {
|
|||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Name: "test-package",
|
||||
RegistryName: "npm",
|
||||
Identifier: "test-package",
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
},
|
||||
},
|
||||
Remotes: []model.Remote{
|
||||
|
|
@ -102,8 +103,9 @@ func TestObjectValidator_Validate(t *testing.T) {
|
|||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Name: "test package with spaces",
|
||||
RegistryName: "npm",
|
||||
Identifier: "test package with spaces",
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -123,12 +125,14 @@ func TestObjectValidator_Validate(t *testing.T) {
|
|||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Name: "valid-package",
|
||||
RegistryName: "npm",
|
||||
Identifier: "valid-package",
|
||||
RegistryType: "npm",
|
||||
RegistryBaseURL: "https://registry.npmjs.org",
|
||||
},
|
||||
{
|
||||
Name: "invalid package", // Has space
|
||||
RegistryName: "pip",
|
||||
Identifier: "invalid package", // Has space
|
||||
RegistryType: "pypi",
|
||||
RegistryBaseURL: "https://pypi.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -91,8 +91,9 @@ cat > "$PAYLOAD_FILE" << EOF
|
|||
},
|
||||
"packages": [
|
||||
{
|
||||
"package_type": "javascript",
|
||||
"registry_name": "npm",
|
||||
"name": "test-mcp-server",
|
||||
"identifier": "test-mcp-server",
|
||||
"version": "1.0.$(date +%s)",
|
||||
"runtime_hint": "node",
|
||||
"runtime_arguments": [
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// OldServerFormat represents the legacy seed format
|
||||
type OldServerFormat struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Repository model.Repository `json:"repository"`
|
||||
VersionDetail OldVersionDetail `json:"version_detail"`
|
||||
Packages []model.Package `json:"packages,omitempty"`
|
||||
Remotes []model.Remote `json:"remotes,omitempty"`
|
||||
Extensions map[string]interface{} `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// OldVersionDetail represents the legacy version format with registry metadata
|
||||
type OldVersionDetail struct {
|
||||
Version string `json:"version"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
IsLatest bool `json:"is_latest,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
log.Println("Usage: migrate-seed <input-source> <output-file>")
|
||||
log.Println(" input-source: file path or HTTP URL")
|
||||
log.Println(" output-file: path for the migrated seed file")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
inputSource := os.Args[1]
|
||||
outputFile := os.Args[2]
|
||||
|
||||
log.Printf("Migrating seed from %s to %s", inputSource, outputFile)
|
||||
|
||||
// Read source data
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(inputSource, "http://") || strings.HasPrefix(inputSource, "https://") {
|
||||
data, err = fetchFromHTTP(inputSource)
|
||||
} else {
|
||||
data, err = os.ReadFile(inputSource)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read input source: %v", err)
|
||||
}
|
||||
|
||||
// Parse old format
|
||||
var oldServers []OldServerFormat
|
||||
if err := json.Unmarshal(data, &oldServers); err != nil {
|
||||
log.Fatalf("Failed to parse old format: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Found %d servers to migrate", len(oldServers))
|
||||
|
||||
// Convert to new format
|
||||
var newServers []model.ServerResponse
|
||||
for _, old := range oldServers {
|
||||
converted := convertServer(old)
|
||||
newServers = append(newServers, converted)
|
||||
}
|
||||
|
||||
// Write migrated data
|
||||
migratedData, err := json.MarshalIndent(newServers, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal migrated data: %v", err)
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(outputFile), 0755); err != nil {
|
||||
log.Fatalf("Failed to create output directory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputFile, migratedData, 0600); err != nil {
|
||||
log.Fatalf("Failed to write output file: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully migrated %d servers to %s", len(newServers), outputFile)
|
||||
}
|
||||
|
||||
func fetchFromHTTP(url string) ([]byte, error) {
|
||||
log.Printf("Fetching data from HTTP: %s", url)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from HTTP: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP request failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func convertServer(old OldServerFormat) model.ServerResponse {
|
||||
// Create pure MCP server specification
|
||||
server := model.ServerDetail{
|
||||
Name: old.Name,
|
||||
Description: old.Description,
|
||||
Repository: old.Repository,
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: old.VersionDetail.Version,
|
||||
},
|
||||
Packages: old.Packages,
|
||||
Remotes: old.Remotes,
|
||||
}
|
||||
|
||||
// Set status if provided, otherwise default to active
|
||||
if old.Status != "" {
|
||||
server.Status = model.ServerStatus(old.Status)
|
||||
} else {
|
||||
server.Status = model.ServerStatusActive
|
||||
}
|
||||
|
||||
// Create registry metadata
|
||||
registryMetadata := model.RegistryMetadata{
|
||||
ID: old.ID,
|
||||
IsLatest: old.VersionDetail.IsLatest,
|
||||
ReleaseDate: old.VersionDetail.ReleaseDate,
|
||||
}
|
||||
|
||||
// Publisher extensions (combine any additional fields)
|
||||
publisherExtensions := make(map[string]interface{})
|
||||
if old.Extensions != nil {
|
||||
for k, v := range old.Extensions {
|
||||
publisherExtensions[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Create the extension wrapper response
|
||||
response := model.ServerResponse{
|
||||
Server: server,
|
||||
}
|
||||
|
||||
// Add registry metadata extension
|
||||
response.XIOModelContextProtocolRegistry = registryMetadata.CreateRegistryExtensions()["x-io.modelcontextprotocol.registry"]
|
||||
|
||||
// Add publisher extensions if any
|
||||
if len(publisherExtensions) > 0 {
|
||||
response.XPublisher = publisherExtensions
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
||||
// createTestLegacySeed creates a small test file with legacy format data
|
||||
func createTestLegacySeed(t *testing.T) string {
|
||||
t.Helper()
|
||||
testData := []OldServerFormat{
|
||||
{
|
||||
ID: "4e9cf4cf-71f6-4aca-bae8-2d10a29ca2e0",
|
||||
Name: "io.github.21st-dev/magic-mcp",
|
||||
Description: "It's like v0 but in your Cursor/WindSurf/Cline. 21st dev Magic MCP server for working with your frontend like Magic",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/21st-dev/magic-mcp",
|
||||
Source: "github",
|
||||
ID: "935450522",
|
||||
},
|
||||
VersionDetail: OldVersionDetail{
|
||||
Version: "0.0.1-seed",
|
||||
ReleaseDate: "2025-05-16T18:56:49Z",
|
||||
IsLatest: true,
|
||||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "@21st-dev/magic",
|
||||
Version: "0.0.46",
|
||||
EnvironmentVariables: []model.KeyValueInput{
|
||||
{
|
||||
Name: "API_KEY",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "${input:apiKey}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "d3669201-252f-403c-944b-c3ec0845782b",
|
||||
Name: "io.github.adfin-engineering/mcp-server-adfin",
|
||||
Description: "A Model Context Protocol Server for connecting with Adfin APIs",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/Adfin-Engineering/mcp-server-adfin",
|
||||
Source: "github",
|
||||
ID: "951338147",
|
||||
},
|
||||
VersionDetail: OldVersionDetail{
|
||||
Version: "0.0.1-seed",
|
||||
ReleaseDate: "2025-05-16T18:56:52Z",
|
||||
IsLatest: true,
|
||||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "pypi",
|
||||
Name: "adfinmcp",
|
||||
Version: "0.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("", "test-legacy-seed-*.json")
|
||||
require.NoError(t, err)
|
||||
defer tempFile.Close()
|
||||
|
||||
jsonData, err := json.MarshalIndent(testData, "", " ")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tempFile.Write(jsonData)
|
||||
require.NoError(t, err)
|
||||
|
||||
return tempFile.Name()
|
||||
}
|
||||
|
||||
func TestMigrationCLI(t *testing.T) {
|
||||
// Create test legacy seed file
|
||||
legacyFile := createTestLegacySeed(t)
|
||||
defer os.Remove(legacyFile)
|
||||
|
||||
// Create output file path
|
||||
outputFile := filepath.Join(t.TempDir(), "migrated-output.json")
|
||||
|
||||
// Run migration (simulate command line execution)
|
||||
err := migrateSeed(legacyFile, outputFile)
|
||||
require.NoError(t, err, "Migration should succeed")
|
||||
|
||||
// Read the output file
|
||||
outputData, err := os.ReadFile(outputFile)
|
||||
require.NoError(t, err, "Should be able to read output file")
|
||||
|
||||
// Parse as ServerResponse array (new format)
|
||||
var migratedServers []model.ServerResponse
|
||||
err = json.Unmarshal(outputData, &migratedServers)
|
||||
require.NoError(t, err, "Output should be valid JSON in ServerResponse format")
|
||||
|
||||
// Verify we have the expected number of servers
|
||||
assert.Len(t, migratedServers, 2, "Should have migrated 2 servers")
|
||||
|
||||
// Verify first server structure
|
||||
server1 := migratedServers[0]
|
||||
assert.Equal(t, "io.github.21st-dev/magic-mcp", server1.Server.Name)
|
||||
assert.Equal(t, "It's like v0 but in your Cursor/WindSurf/Cline. 21st dev Magic MCP server for working with your frontend like Magic", server1.Server.Description)
|
||||
assert.Equal(t, "https://github.com/21st-dev/magic-mcp", server1.Server.Repository.URL)
|
||||
assert.Equal(t, "0.0.1-seed", server1.Server.VersionDetail.Version)
|
||||
assert.Len(t, server1.Server.Packages, 1)
|
||||
assert.Equal(t, "npm", server1.Server.Packages[0].RegistryName)
|
||||
assert.Equal(t, "@21st-dev/magic", server1.Server.Packages[0].Name)
|
||||
|
||||
// Verify registry metadata extension
|
||||
assert.NotNil(t, server1.XIOModelContextProtocolRegistry, "Should have registry metadata extension")
|
||||
registryMeta1, ok := server1.XIOModelContextProtocolRegistry.(map[string]interface{})
|
||||
require.True(t, ok, "Registry metadata should be a map")
|
||||
assert.Equal(t, "4e9cf4cf-71f6-4aca-bae8-2d10a29ca2e0", registryMeta1["id"])
|
||||
assert.Equal(t, true, registryMeta1["is_latest"])
|
||||
assert.Equal(t, "2025-05-16T18:56:49Z", registryMeta1["release_date"])
|
||||
|
||||
// Verify second server structure
|
||||
server2 := migratedServers[1]
|
||||
assert.Equal(t, "io.github.adfin-engineering/mcp-server-adfin", server2.Server.Name)
|
||||
assert.Equal(t, "A Model Context Protocol Server for connecting with Adfin APIs", server2.Server.Description)
|
||||
assert.Equal(t, "pypi", server2.Server.Packages[0].RegistryName)
|
||||
assert.Equal(t, "adfinmcp", server2.Server.Packages[0].Name)
|
||||
|
||||
// Verify registry metadata for second server
|
||||
registryMeta2, ok := server2.XIOModelContextProtocolRegistry.(map[string]interface{})
|
||||
require.True(t, ok, "Registry metadata should be a map")
|
||||
assert.Equal(t, "d3669201-252f-403c-944b-c3ec0845782b", registryMeta2["id"])
|
||||
assert.Equal(t, true, registryMeta2["is_latest"])
|
||||
assert.Equal(t, "2025-05-16T18:56:52Z", registryMeta2["release_date"])
|
||||
|
||||
// Verify no x-publisher extensions (should be empty/nil for seed data)
|
||||
assert.Nil(t, server1.XPublisher, "Seed data should not have x-publisher extensions")
|
||||
assert.Nil(t, server2.XPublisher, "Seed data should not have x-publisher extensions")
|
||||
}
|
||||
|
||||
// migrateSeed is a testable version of the main migration logic
|
||||
func migrateSeed(inputFile, outputFile string) error {
|
||||
// Read source data
|
||||
data, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse old format
|
||||
var oldServers []OldServerFormat
|
||||
if err := json.Unmarshal(data, &oldServers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert to new format
|
||||
var newServers []model.ServerResponse
|
||||
for _, old := range oldServers {
|
||||
converted := convertServer(old)
|
||||
newServers = append(newServers, converted)
|
||||
}
|
||||
|
||||
// Write migrated data
|
||||
migratedData, err := json.MarshalIndent(newServers, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(outputFile), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(outputFile, migratedData, 0600)
|
||||
}
|
||||
|
||||
func TestMigrationCLI_CommandLine(t *testing.T) {
|
||||
// Create test legacy seed file
|
||||
legacyFile := createTestLegacySeed(t)
|
||||
defer os.Remove(legacyFile)
|
||||
|
||||
// Create output file path
|
||||
outputFile := filepath.Join(t.TempDir(), "cli-migrated-output.json")
|
||||
|
||||
// Simulate command line arguments
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = []string{"migrate-seed", legacyFile, outputFile}
|
||||
|
||||
// Capture any panics or exits from main()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("main() panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test the main function (Note: this would normally call os.Exit, but in tests it should return)
|
||||
main()
|
||||
|
||||
// Verify the output file was created and has correct content
|
||||
require.FileExists(t, outputFile, "Output file should be created")
|
||||
|
||||
outputData, err := os.ReadFile(outputFile)
|
||||
require.NoError(t, err, "Should be able to read output file")
|
||||
|
||||
var migratedServers []model.ServerResponse
|
||||
err = json.Unmarshal(outputData, &migratedServers)
|
||||
require.NoError(t, err, "Output should be valid JSON in ServerResponse format")
|
||||
|
||||
// Basic verification
|
||||
assert.Len(t, migratedServers, 2, "Should have migrated 2 servers")
|
||||
assert.Equal(t, "io.github.21st-dev/magic-mcp", migratedServers[0].Server.Name)
|
||||
assert.Equal(t, "io.github.adfin-engineering/mcp-server-adfin", migratedServers[1].Server.Name)
|
||||
}
|
||||
|
||||
func TestMigrationCLI_ErrorHandling(t *testing.T) {
|
||||
t.Run("Invalid JSON input", func(t *testing.T) {
|
||||
// Create invalid JSON file
|
||||
invalidFile, err := os.CreateTemp("", "invalid-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(invalidFile.Name())
|
||||
|
||||
_, err = invalidFile.WriteString(`{"invalid": json}`)
|
||||
require.NoError(t, err)
|
||||
invalidFile.Close()
|
||||
|
||||
outputFile := filepath.Join(t.TempDir(), "output.json")
|
||||
err = migrateSeed(invalidFile.Name(), outputFile)
|
||||
assert.Error(t, err, "Should error on invalid JSON")
|
||||
})
|
||||
|
||||
t.Run("Missing input file", func(t *testing.T) {
|
||||
outputFile := filepath.Join(t.TempDir(), "output.json")
|
||||
err := migrateSeed("/nonexistent/file.json", outputFile)
|
||||
assert.Error(t, err, "Should error on missing input file")
|
||||
})
|
||||
|
||||
t.Run("Empty input file", func(t *testing.T) {
|
||||
// Create empty JSON array
|
||||
emptyFile, err := os.CreateTemp("", "empty-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(emptyFile.Name())
|
||||
|
||||
_, err = emptyFile.WriteString("[]")
|
||||
require.NoError(t, err)
|
||||
emptyFile.Close()
|
||||
|
||||
outputFile := filepath.Join(t.TempDir(), "output.json")
|
||||
err = migrateSeed(emptyFile.Name(), outputFile)
|
||||
assert.NoError(t, err, "Empty array should be valid")
|
||||
|
||||
// Verify empty output
|
||||
outputData, err := os.ReadFile(outputFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []model.ServerResponse
|
||||
err = json.Unmarshal(outputData, &result)
|
||||
require.NoError(t, err, "Should be valid JSON")
|
||||
assert.Len(t, result, 0, "Should have no servers")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrationCLI_GoldenFile(t *testing.T) {
|
||||
// Test migration with predefined input and expected output files
|
||||
inputFile := "testdata/legacy-input.json"
|
||||
expectedFile := "testdata/expected-output.json"
|
||||
|
||||
// Ensure test files exist
|
||||
require.FileExists(t, inputFile, "Input test file should exist")
|
||||
require.FileExists(t, expectedFile, "Expected output test file should exist")
|
||||
|
||||
// Run migration on test input
|
||||
outputFile := filepath.Join(t.TempDir(), "golden-output.json")
|
||||
err := migrateSeed(inputFile, outputFile)
|
||||
require.NoError(t, err, "Migration should succeed")
|
||||
|
||||
// Read expected and actual output
|
||||
expectedData, err := os.ReadFile(expectedFile)
|
||||
require.NoError(t, err, "Should be able to read expected output")
|
||||
|
||||
actualData, err := os.ReadFile(outputFile)
|
||||
require.NoError(t, err, "Should be able to read actual output")
|
||||
|
||||
// Parse both as JSON to normalize formatting and compare semantically
|
||||
var expectedJSON, actualJSON []model.ServerResponse
|
||||
|
||||
err = json.Unmarshal(expectedData, &expectedJSON)
|
||||
require.NoError(t, err, "Expected output should be valid JSON")
|
||||
|
||||
err = json.Unmarshal(actualData, &actualJSON)
|
||||
require.NoError(t, err, "Actual output should be valid JSON")
|
||||
|
||||
// Compare the parsed structures
|
||||
assert.Equal(t, expectedJSON, actualJSON, "Migration output should match expected golden file")
|
||||
|
||||
// Also compare server count
|
||||
assert.Len(t, actualJSON, len(expectedJSON), "Should have same number of servers")
|
||||
|
||||
// Verify specific server names to ensure ordering is preserved
|
||||
if len(expectedJSON) >= 2 && len(actualJSON) >= 2 {
|
||||
assert.Equal(t, expectedJSON[0].Server.Name, actualJSON[0].Server.Name, "First server name should match")
|
||||
assert.Equal(t, expectedJSON[1].Server.Name, actualJSON[1].Server.Name, "Second server name should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldenFiles_Validation(t *testing.T) {
|
||||
// Validate that the golden files themselves are valid
|
||||
t.Run("Input file is valid legacy format", func(t *testing.T) {
|
||||
inputData, err := os.ReadFile("testdata/legacy-input.json")
|
||||
require.NoError(t, err, "Should be able to read input file")
|
||||
|
||||
var legacyServers []OldServerFormat
|
||||
err = json.Unmarshal(inputData, &legacyServers)
|
||||
require.NoError(t, err, "Input file should be valid legacy format JSON")
|
||||
|
||||
assert.Len(t, legacyServers, 2, "Input should contain exactly 2 servers")
|
||||
|
||||
// Verify it contains expected server names
|
||||
names := []string{legacyServers[0].Name, legacyServers[1].Name}
|
||||
assert.Contains(t, names, "io.github.21st-dev/magic-mcp")
|
||||
assert.Contains(t, names, "io.github.adfin-engineering/mcp-server-adfin")
|
||||
})
|
||||
|
||||
t.Run("Expected output is valid extension wrapper format", func(t *testing.T) {
|
||||
expectedData, err := os.ReadFile("testdata/expected-output.json")
|
||||
require.NoError(t, err, "Should be able to read expected output file")
|
||||
|
||||
var expectedServers []model.ServerResponse
|
||||
err = json.Unmarshal(expectedData, &expectedServers)
|
||||
require.NoError(t, err, "Expected output should be valid ServerResponse format JSON")
|
||||
|
||||
assert.Len(t, expectedServers, 2, "Expected output should contain exactly 2 servers")
|
||||
|
||||
// Verify structure of first server
|
||||
server1 := expectedServers[0]
|
||||
assert.NotEmpty(t, server1.Server.Name, "Server should have a name")
|
||||
assert.NotEmpty(t, server1.Server.Description, "Server should have a description")
|
||||
assert.NotNil(t, server1.XIOModelContextProtocolRegistry, "Should have registry metadata")
|
||||
assert.Nil(t, server1.XPublisher, "Seed data should not have publisher extensions")
|
||||
|
||||
// Verify registry metadata contains required fields
|
||||
registryMeta, ok := server1.XIOModelContextProtocolRegistry.(map[string]interface{})
|
||||
require.True(t, ok, "Registry metadata should be a map")
|
||||
assert.Contains(t, registryMeta, "id", "Registry metadata should have id")
|
||||
assert.Contains(t, registryMeta, "is_latest", "Registry metadata should have is_latest")
|
||||
assert.Contains(t, registryMeta, "release_date", "Registry metadata should have release_date")
|
||||
})
|
||||
}
|
||||
38
tools/migrate-seed/testdata/README.md
vendored
38
tools/migrate-seed/testdata/README.md
vendored
|
|
@ -1,38 +0,0 @@
|
|||
# Migration Test Data
|
||||
|
||||
This directory contains golden files for testing the seed migration CLI tool.
|
||||
|
||||
## Files
|
||||
|
||||
- **`legacy-input.json`** - Sample input file in the legacy seed format (flat structure with registry metadata mixed in)
|
||||
- **`expected-output.json`** - Expected output after migration to extension wrapper format
|
||||
|
||||
## Usage
|
||||
|
||||
The golden file test (`TestMigrationCLI_GoldenFile`) runs the migration tool on `legacy-input.json` and compares the output against `expected-output.json` to ensure the migration produces the correct result.
|
||||
|
||||
## Updating Golden Files
|
||||
|
||||
If you make changes to the migration logic, you may need to update the expected output:
|
||||
|
||||
1. Review the changes to ensure they're correct
|
||||
2. Regenerate the expected output:
|
||||
```bash
|
||||
cd cmd/migrate-seed
|
||||
../../bin/migrate-seed testdata/legacy-input.json testdata/expected-output.json
|
||||
```
|
||||
3. Verify the new output is correct by reviewing the diff
|
||||
4. Run the tests to ensure they pass
|
||||
|
||||
## Format Details
|
||||
|
||||
### Legacy Input Format
|
||||
- Flat JSON array of server objects
|
||||
- Registry metadata fields (`id`, `is_latest`, `release_date`) mixed directly in server objects
|
||||
- Version detail contains registry metadata
|
||||
|
||||
### Expected Output Format
|
||||
- Extension wrapper format
|
||||
- Each server wrapped in `"server"` field
|
||||
- Registry metadata in `"x-io.modelcontextprotocol.registry"` extension
|
||||
- No `"x-publisher"` extensions for seed data
|
||||
77
tools/migrate-seed/testdata/expected-output.json
vendored
77
tools/migrate-seed/testdata/expected-output.json
vendored
|
|
@ -1,77 +0,0 @@
|
|||
[
|
||||
{
|
||||
"server": {
|
||||
"name": "io.github.21st-dev/magic-mcp",
|
||||
"description": "It's like v0 but in your Cursor/WindSurf/Cline. 21st dev Magic MCP server for working with your frontend like Magic",
|
||||
"status": "active",
|
||||
"repository": {
|
||||
"url": "https://github.com/21st-dev/magic-mcp",
|
||||
"source": "github",
|
||||
"id": "935450522"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "0.0.1-seed"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "npm",
|
||||
"name": "@21st-dev/magic",
|
||||
"version": "0.0.46",
|
||||
"environment_variables": [
|
||||
{
|
||||
"description": "${input:apiKey}",
|
||||
"name": "API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"x-io.modelcontextprotocol.registry": {
|
||||
"id": "4e9cf4cf-71f6-4aca-bae8-2d10a29ca2e0",
|
||||
"is_latest": true,
|
||||
"published_at": "0001-01-01T00:00:00Z",
|
||||
"release_date": "2025-05-16T18:56:49Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"server": {
|
||||
"name": "io.github.adfin-engineering/mcp-server-adfin",
|
||||
"description": "A Model Context Protocol Server for connecting with Adfin APIs",
|
||||
"status": "active",
|
||||
"repository": {
|
||||
"url": "https://github.com/Adfin-Engineering/mcp-server-adfin",
|
||||
"source": "github",
|
||||
"id": "951338147"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "0.0.1-seed"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "pypi",
|
||||
"name": "adfinmcp",
|
||||
"version": "0.1.0",
|
||||
"package_arguments": [
|
||||
{
|
||||
"description": "Path to the configuration file",
|
||||
"is_required": true,
|
||||
"format": "string",
|
||||
"value": "/config/adfin.json",
|
||||
"default": "/config/adfin.json",
|
||||
"type": "positional",
|
||||
"value_hint": "/path/to/config.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"x-io.modelcontextprotocol.registry": {
|
||||
"id": "d3669201-252f-403c-944b-c3ec0845782b",
|
||||
"is_latest": true,
|
||||
"published_at": "0001-01-01T00:00:00Z",
|
||||
"release_date": "2025-05-16T18:56:52Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
63
tools/migrate-seed/testdata/legacy-input.json
vendored
63
tools/migrate-seed/testdata/legacy-input.json
vendored
|
|
@ -1,63 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "4e9cf4cf-71f6-4aca-bae8-2d10a29ca2e0",
|
||||
"name": "io.github.21st-dev/magic-mcp",
|
||||
"description": "It's like v0 but in your Cursor/WindSurf/Cline. 21st dev Magic MCP server for working with your frontend like Magic",
|
||||
"repository": {
|
||||
"url": "https://github.com/21st-dev/magic-mcp",
|
||||
"source": "github",
|
||||
"id": "935450522"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "0.0.1-seed",
|
||||
"release_date": "2025-05-16T18:56:49Z",
|
||||
"is_latest": true
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "npm",
|
||||
"name": "@21st-dev/magic",
|
||||
"version": "0.0.46",
|
||||
"environment_variables": [
|
||||
{
|
||||
"description": "${input:apiKey}",
|
||||
"name": "API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "d3669201-252f-403c-944b-c3ec0845782b",
|
||||
"name": "io.github.adfin-engineering/mcp-server-adfin",
|
||||
"description": "A Model Context Protocol Server for connecting with Adfin APIs",
|
||||
"repository": {
|
||||
"url": "https://github.com/Adfin-Engineering/mcp-server-adfin",
|
||||
"source": "github",
|
||||
"id": "951338147"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "0.0.1-seed",
|
||||
"release_date": "2025-05-16T18:56:52Z",
|
||||
"is_latest": true
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_name": "pypi",
|
||||
"name": "adfinmcp",
|
||||
"version": "0.1.0",
|
||||
"package_arguments": [
|
||||
{
|
||||
"description": "Path to the configuration file",
|
||||
"is_required": true,
|
||||
"format": "string",
|
||||
"value": "/config/adfin.json",
|
||||
"default": "/config/adfin.json",
|
||||
"type": "positional",
|
||||
"value_hint": "/path/to/config.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -16,6 +16,16 @@ import (
|
|||
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
|
||||
)
|
||||
|
||||
// Package type constants
|
||||
const (
|
||||
packageTypeDocker = "docker"
|
||||
registryNPM = "npm"
|
||||
registryPyPI = "pypi"
|
||||
registryDocker = "docker"
|
||||
registryNuGet = "nuget"
|
||||
registryURL = "url"
|
||||
)
|
||||
|
||||
// Server structure types for JSON generation
|
||||
type Repository struct {
|
||||
URL string `json:"url"`
|
||||
|
|
@ -42,9 +52,10 @@ type RuntimeArgument struct {
|
|||
}
|
||||
|
||||
type Package struct {
|
||||
RegistryName string `json:"registry_name"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
RegistryType string `json:"registry_type,omitempty"`
|
||||
RegistryBaseURL string `json:"registry_base_url,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
RuntimeHint string `json:"runtime_hint,omitempty"`
|
||||
RuntimeArguments []RuntimeArgument `json:"runtime_arguments,omitempty"`
|
||||
PackageArguments []RuntimeArgument `json:"package_arguments,omitempty"`
|
||||
|
|
@ -226,7 +237,7 @@ func createCommand() error {
|
|||
createFlags.StringVar(&output, "o", "server.json", "Output file path (shorthand)")
|
||||
createFlags.StringVar(&status, "status", "active", "Server status (active or deprecated)")
|
||||
|
||||
createFlags.StringVar(®istryName, "registry", "npm", "Package registry name")
|
||||
createFlags.StringVar(®istryName, "registry", registryNPM, "Package registry name")
|
||||
createFlags.StringVar(&packageName, "package-name", "", "Package name (defaults to server name)")
|
||||
createFlags.StringVar(&packageVersion, "package-version", "", "Package version (defaults to server version)")
|
||||
createFlags.StringVar(&runtimeHint, "runtime-hint", "", "Runtime hint (e.g., docker)")
|
||||
|
|
@ -304,9 +315,9 @@ func createCommand() error {
|
|||
// Set runtime hint based on registry name if not explicitly provided
|
||||
if runtimeHint == "" {
|
||||
switch registryName {
|
||||
case "docker":
|
||||
runtimeHint = "docker"
|
||||
case "npm":
|
||||
case packageTypeDocker:
|
||||
runtimeHint = packageTypeDocker
|
||||
case registryNPM:
|
||||
runtimeHint = "npx"
|
||||
}
|
||||
}
|
||||
|
|
@ -398,12 +409,8 @@ func publishToRegistry(registryURL string, mcpData []byte, token string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func createServerStructure(
|
||||
name, description, version, repoURL, repoSource, registryName,
|
||||
packageName, packageVersion, runtimeHint, execute string,
|
||||
envVars []string, packageArgs []string, status string,
|
||||
) ServerJSON {
|
||||
// Parse environment variables
|
||||
// parseEnvironmentVariables parses environment variable specifications
|
||||
func parseEnvironmentVariables(envVars []string) []EnvironmentVariable {
|
||||
var environmentVariables []EnvironmentVariable
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, ":", 2)
|
||||
|
|
@ -420,8 +427,11 @@ func createServerStructure(
|
|||
})
|
||||
}
|
||||
}
|
||||
return environmentVariables
|
||||
}
|
||||
|
||||
// Parse package arguments
|
||||
// parsePackageArguments parses package argument specifications
|
||||
func parsePackageArguments(packageArgs []string) []RuntimeArgument {
|
||||
var packageArguments []RuntimeArgument
|
||||
for i, pkgArg := range packageArgs {
|
||||
parts := strings.SplitN(pkgArg, ":", 2)
|
||||
|
|
@ -441,46 +451,121 @@ func createServerStructure(
|
|||
ValueHint: value,
|
||||
})
|
||||
}
|
||||
return packageArguments
|
||||
}
|
||||
|
||||
// Parse execute command to create runtime arguments
|
||||
// parseRuntimeArguments parses the execute command to create runtime arguments
|
||||
func parseRuntimeArguments(execute string) []RuntimeArgument {
|
||||
var runtimeArguments []RuntimeArgument
|
||||
if execute != "" {
|
||||
// Split the execute command into parts, handling quoted strings
|
||||
parts := smartSplit(execute)
|
||||
if len(parts) > 1 {
|
||||
// Skip the first part (command) and add each argument as a runtime argument
|
||||
for i, arg := range parts[1:] {
|
||||
description := fmt.Sprintf("Runtime argument %d", i+1)
|
||||
|
||||
// Try to provide better descriptions based on common patterns
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--"):
|
||||
description = fmt.Sprintf("Command line flag: %s", arg)
|
||||
case strings.HasPrefix(arg, "-") && len(arg) == 2:
|
||||
description = fmt.Sprintf("Command line option: %s", arg)
|
||||
case strings.Contains(arg, "="):
|
||||
description = fmt.Sprintf("Configuration parameter: %s", arg)
|
||||
case i > 0 && strings.HasPrefix(parts[i], "-"):
|
||||
description = fmt.Sprintf("Value for %s", parts[i])
|
||||
}
|
||||
|
||||
runtimeArguments = append(runtimeArguments, RuntimeArgument{
|
||||
Description: description,
|
||||
IsRequired: false,
|
||||
Format: "string",
|
||||
Value: arg,
|
||||
Default: arg,
|
||||
Type: "positional",
|
||||
ValueHint: arg,
|
||||
})
|
||||
}
|
||||
}
|
||||
if execute == "" {
|
||||
return runtimeArguments
|
||||
}
|
||||
|
||||
// Create package
|
||||
// Split the execute command into parts, handling quoted strings
|
||||
parts := smartSplit(execute)
|
||||
if len(parts) <= 1 {
|
||||
return runtimeArguments
|
||||
}
|
||||
|
||||
// Skip the first part (command) and add each argument as a runtime argument
|
||||
for i, arg := range parts[1:] {
|
||||
description := getArgumentDescription(arg, i, parts)
|
||||
runtimeArguments = append(runtimeArguments, RuntimeArgument{
|
||||
Description: description,
|
||||
IsRequired: false,
|
||||
Format: "string",
|
||||
Value: arg,
|
||||
Default: arg,
|
||||
Type: "positional",
|
||||
ValueHint: arg,
|
||||
})
|
||||
}
|
||||
|
||||
return runtimeArguments
|
||||
}
|
||||
|
||||
// getArgumentDescription provides a description for a runtime argument
|
||||
func getArgumentDescription(arg string, index int, parts []string) string {
|
||||
description := fmt.Sprintf("Runtime argument %d", index+1)
|
||||
|
||||
// Try to provide better descriptions based on common patterns
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--"):
|
||||
description = fmt.Sprintf("Command line flag: %s", arg)
|
||||
case strings.HasPrefix(arg, "-") && len(arg) == 2:
|
||||
description = fmt.Sprintf("Command line option: %s", arg)
|
||||
case strings.Contains(arg, "="):
|
||||
description = fmt.Sprintf("Configuration parameter: %s", arg)
|
||||
case index > 0 && strings.HasPrefix(parts[index], "-"):
|
||||
description = fmt.Sprintf("Value for %s", parts[index])
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
func createServerStructure(
|
||||
name, description, version, repoURL, repoSource, registryName,
|
||||
packageName, packageVersion, runtimeHint, execute string,
|
||||
envVars []string, packageArgs []string, status string,
|
||||
) ServerJSON {
|
||||
// Parse environment variables
|
||||
environmentVariables := parseEnvironmentVariables(envVars)
|
||||
|
||||
// Parse package arguments
|
||||
packageArguments := parsePackageArguments(packageArgs)
|
||||
|
||||
// Parse execute command to create runtime arguments
|
||||
runtimeArguments := parseRuntimeArguments(execute)
|
||||
|
||||
// Determine registry_type and registry_base_url from registryName
|
||||
var registryTypeValue, registryBaseURLValue, identifier string
|
||||
switch registryName {
|
||||
case registryNPM:
|
||||
registryTypeValue = "npm"
|
||||
registryBaseURLValue = "https://registry.npmjs.org"
|
||||
identifier = packageName
|
||||
case registryPyPI:
|
||||
registryTypeValue = "pypi"
|
||||
registryBaseURLValue = "https://pypi.org"
|
||||
identifier = packageName
|
||||
case registryDocker:
|
||||
registryTypeValue = "docker-hub"
|
||||
registryBaseURLValue = "https://docker.io"
|
||||
identifier = packageName
|
||||
case registryNuGet:
|
||||
registryTypeValue = "nuget"
|
||||
registryBaseURLValue = "https://api.nuget.org"
|
||||
identifier = packageName
|
||||
case registryURL:
|
||||
// For URL-based packages, determine registry from URL and file type
|
||||
if strings.HasSuffix(strings.ToLower(packageName), ".mcpb") {
|
||||
registryTypeValue = "mcpb"
|
||||
// Determine base URL from the download URL
|
||||
switch {
|
||||
case strings.Contains(packageName, "github.com"):
|
||||
registryBaseURLValue = "https://github.com"
|
||||
case strings.Contains(packageName, "gitlab.com"):
|
||||
registryBaseURLValue = "https://gitlab.com"
|
||||
default:
|
||||
registryBaseURLValue = ""
|
||||
}
|
||||
} else {
|
||||
registryTypeValue = "url"
|
||||
registryBaseURLValue = ""
|
||||
}
|
||||
identifier = packageName
|
||||
default:
|
||||
// Unknown or custom registry
|
||||
registryTypeValue = registryName
|
||||
registryBaseURLValue = ""
|
||||
identifier = packageName
|
||||
}
|
||||
|
||||
// Create package with new structured fields
|
||||
pkg := Package{
|
||||
RegistryName: registryName,
|
||||
Name: packageName,
|
||||
RegistryType: registryTypeValue,
|
||||
RegistryBaseURL: registryBaseURLValue,
|
||||
Identifier: identifier,
|
||||
Version: packageVersion,
|
||||
RuntimeHint: runtimeHint,
|
||||
RuntimeArguments: runtimeArguments,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
"status": "active",
|
||||
"packages": [
|
||||
{
|
||||
"package_type": "javascript",
|
||||
"registry_name": "npm",
|
||||
"name": "io.github.<owner>/<server-name>",
|
||||
"identifier": "io.github.<owner>/<server-name>",
|
||||
"version": "0.2.23",
|
||||
"package_arguments": [
|
||||
{
|
||||
|
|
@ -26,8 +27,9 @@
|
|||
}
|
||||
]
|
||||
},{
|
||||
"registry_name": "docker",
|
||||
"name": "io.github.<owner>/<server-name>-cli",
|
||||
"package_type": "docker",
|
||||
"registry_name": "docker-hub",
|
||||
"identifier": "io.github.<owner>/<server-name>-cli:0.123.223",
|
||||
"version": "0.123.223",
|
||||
"runtime_hint": "docker",
|
||||
"runtime_arguments": [
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const (
|
|||
// IMPORTANT: Only change this count if you have intentionally added or removed examples
|
||||
// from the examples.md file. This check prevents accidental formatting changes from
|
||||
// causing examples to be skipped during validation.
|
||||
expectedExampleCount = 9
|
||||
expectedExampleCount = 10
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue