Wielded Claude Code to do this; across many iterations and have reviewed all the code + largely rewritten the PR commentary here. This PR implements the server ID consistency changes from [#396](https://github.com/modelcontextprotocol/registry/issues/396). Our goal is to: - Rename `id` to `versionId` to make it clear that it is the ID for a specific version, not a server - Introduce `serverId` as a net new concept. This will be 1:1 with `name`. While not strictly necessary (we could just use name as a PK), the convention of having a UUID as an ID on our objects is just a helpful subjective architecture decision. Plus also leaves open the door to reconsidering the "should we allow renames" question. ## Key Changes ### 🗄️ Database Schema Changes - **Renamed**: `id` column → `version_id` (unique identifier for each server version) ### 📋 Metadata Structure Updates - **JSON Schema**: Updated `_meta.io.modelcontextprotocol.registry/official` to include: - `serverId`: Consistent ID across all versions of a server - `versionId`: Unique ID for this specific version (renamed from `id`) ### 🔧 API Endpoint Updates - **New endpoint structure**: Changed from version-ID-based to server-ID-based URLs: - `GET /v0/servers/{server_id}` - Get latest version of server - `GET /v0/servers/{server_id}?version=X.X.X` - Get specific version - `GET /v0/servers/{server_id}/versions` - List all versions of a server - `PUT /v0/servers/{server_id}?version=X.X.X` - Edit specific version - **Consistent URL patterns**: All server endpoints now use `server_id` as the primary identifier ### Database Migration Strategy The migration (`005_add_server_id_rename_version_id.sql`): 1. Adds new `version_id` column 2. Generates deterministic UUIDs for servers with the same name 3. Updates JSON metadata to add `serverId` and rename `id` to `versionId` 4. Copies existing `id` values to `version_id` column 5. Drops old `id` column and makes `version_id` the new primary key ## Testing Besides of course automated tests passing, I: 1. Set up my local db with the pre-005-migration state, imported all the production data 2. Ran the migration 3. Spot checked some diffs of the data that resulted -- ```json // before { "name": "io.github.ruvnet/ruv-swarm", "_meta": { "io.modelcontextprotocol.registry/official": { "id": "0a9cda7f-2b73-4839-844b-6582f335de42", "is_latest": false, "updated_at": "2025-09-10T17:21:34.92137012Z", "published_at": "2025-09-10T17:15:32.69690433Z" } }, "version": "1.0.18", "repository": { "url": "", "source": "" }, "description": "Neural network swarm orchestration with WebAssembly acceleration and MCP integration" } // after { "name": "io.github.ruvnet/ruv-swarm", "_meta": { "io.modelcontextprotocol.registry/official": { "serverId": "e8538952-96af-4a33-9e31-7fbed012521e", "is_latest": false, "versionId": "0a9cda7f-2b73-4839-844b-6582f335de42", "updated_at": "2025-09-10T17:21:34.92137012Z", "published_at": "2025-09-10T17:15:32.69690433Z" } }, "version": "1.0.18", "repository": { "url": "", "source": "" }, "description": "Neural network swarm orchestration with WebAssembly acceleration and MCP integration" } ``` Servers that have multiple versions stayed consistent in their `serverId`: ```json // First version { "name": "app.getdialer/dialer", "_meta": { "io.modelcontextprotocol.registry/official": { "serverId": "80f08bdb-f3e4-4f19-b072-1676ae49b663", "is_latest": false, "versionId": "04f25b17-331d-48d7-b08b-37baece208a7", "updated_at": "2025-09-09T00:16:49.162620072Z", "published_at": "2025-09-08T23:47:09.452137971Z" } }, "status": "active", "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "remotes": [ { "url": "https://getdialer.app/mcp", "type": "streamable-http" }, { "url": "https://getdialer.app/sse", "type": "sse" } ], "version": "1.0.0", "repository": { "url": "", "source": "" }, "description": "An MCP server that provides your you make outbound phone calls using your own phone number" } // Second version { "name": "app.getdialer/dialer", "_meta": { "io.modelcontextprotocol.registry/official": { "serverId": "80f08bdb-f3e4-4f19-b072-1676ae49b663", "is_latest": true, "versionId": "faa26cb9-3aad-415e-aece-f715fff5fe5e", "updated_at": "2025-09-09T00:16:49.107842808Z", "published_at": "2025-09-09T00:16:49.107842808Z" } }, "status": "active", "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "remotes": [ { "url": "https://getdialer.app/mcp", "type": "streamable-http" }, { "url": "https://getdialer.app/sse", "type": "sse" } ], "version": "1.0.1", "repository": { "url": "", "source": "" }, "description": "An MCP server that provides lets you make outbound phone calls using your own phone number" } ``` And I also tried publishing a test server anew; looked good: ```bash ⏺ Bash(curl -s "http://localhost:8080/v0/servers/102dc4e4-6ca8-40e6-aa11-cd45763d3352" | jq '.') ⎿ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.modelcontextprotocol.anonymous/test-claude-migration-server", "description": "A test server to verify the new migration and API endpoints work correctly", "status": "active", "repository": { "url": "https://github.com/example/test-claude-migration-server", "source": "github", "id": "example/test-claude-migration-server" }, "version": "1.0.0", "_meta": { "io.modelcontextprotocol.registry/official": { "serverId": "102dc4e4-6ca8-40e6-aa11-cd45763d3352", "versionId": "48c9cd55-7bc4-4ac2-896e-59e81eb653e4", "published_at": "2025-09-12T18:33:23.264203-07:00", "updated_at": "2025-09-12T18:33:23.264203-07:00", "is_latest": true } } } ⏺ Bash(curl -s "http://localhost:8080/v0/servers/102dc4e4-6ca8-40e6-aa11-cd45763d3352/versions" | jq '.') ⎿ { "servers": [ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.modelcontextprotocol.anonymous/test-claude-migration-server", "description": "A test server to verify the new migration and API endpoints work correctly", "status": "active", "repository": { "url": "https://github.com/example/test-claude-migration-server", "source": "github", "id": "example/test-claude-migration-server" }, "version": "1.0.0", "_meta": { "io.modelcontextprotocol.registry/official": { "serverId": "102dc4e4-6ca8-40e6-aa11-cd45763d3352", "versionId": "48c9cd55-7bc4-4ac2-896e-59e81eb653e4", "published_at": "2025-09-12T18:33:23.264203-07:00", "updated_at": "2025-09-12T18:33:23.264203-07:00", "is_latest": true } } } ], "metadata": { "count": 1 } } ``` Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Preeti Dewani <preetidewani1990@gmail.com> Co-authored-by: adam jones <adamj+git@anthropic.com> Co-authored-by: Adam Jones <adamj@anthropic.com>
315 lines
8.1 KiB
Go
315 lines
8.1 KiB
Go
package commands
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
)
|
|
|
|
func InitCommand() error {
|
|
// Check if server.json already exists
|
|
if _, err := os.Stat("server.json"); err == nil {
|
|
return errors.New("server.json already exists")
|
|
}
|
|
|
|
// Try to detect values from environment
|
|
name := detectServerName()
|
|
description := detectDescription()
|
|
version := "1.0.0"
|
|
repoURL := detectRepoURL()
|
|
repoSource := "github"
|
|
if repoURL != "" && !strings.Contains(repoURL, "github.com") {
|
|
if strings.Contains(repoURL, "gitlab.com") {
|
|
repoSource = "gitlab"
|
|
} else {
|
|
repoSource = "git"
|
|
}
|
|
}
|
|
|
|
packageType := detectPackageType()
|
|
packageIdentifier := detectPackageIdentifier(name, packageType)
|
|
|
|
// Create example environment variables
|
|
envVars := []model.KeyValueInput{
|
|
{
|
|
Name: "YOUR_API_KEY",
|
|
InputWithVariables: model.InputWithVariables{
|
|
Input: model.Input{
|
|
Description: "Your API key for the service",
|
|
IsRequired: true,
|
|
IsSecret: true,
|
|
Format: model.FormatString,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create the server structure
|
|
server := createServerJSON(
|
|
name, description, version, repoURL, repoSource,
|
|
packageType, packageIdentifier, version, envVars,
|
|
)
|
|
|
|
// Write to file
|
|
jsonData, err := json.MarshalIndent(server, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("error marshaling JSON: %w", err)
|
|
}
|
|
|
|
err = os.WriteFile("server.json", jsonData, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("error writing file: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(os.Stdout, "Created server.json")
|
|
_, _ = fmt.Fprintln(os.Stdout, "\nEdit server.json to update:")
|
|
_, _ = fmt.Fprintln(os.Stdout, " • Server name and description")
|
|
_, _ = fmt.Fprintln(os.Stdout, " • Package details")
|
|
_, _ = fmt.Fprintln(os.Stdout, " • Environment variables")
|
|
_, _ = fmt.Fprintln(os.Stdout, "\nThen publish with:")
|
|
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github # or your preferred auth method")
|
|
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish")
|
|
|
|
return nil
|
|
}
|
|
|
|
func getNameFromPackageJSON() string {
|
|
data, err := os.ReadFile("package.json")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
return ""
|
|
}
|
|
|
|
name, ok := pkg["name"].(string)
|
|
if !ok || name == "" {
|
|
return ""
|
|
}
|
|
|
|
// Convert npm package name to MCP server name
|
|
// @org/package -> io.npm.org/package
|
|
if strings.HasPrefix(name, "@") {
|
|
parts := strings.Split(name[1:], "/")
|
|
if len(parts) == 2 {
|
|
return fmt.Sprintf("io.github.%s/%s", parts[0], parts[1])
|
|
}
|
|
}
|
|
return fmt.Sprintf("io.github.<your-username>/%s", name)
|
|
}
|
|
|
|
func detectServerName() string {
|
|
// Try to get from git remote
|
|
repoURL := detectRepoURL()
|
|
if repoURL != "" {
|
|
// Extract owner/repo from GitHub URL
|
|
if strings.Contains(repoURL, "github.com") {
|
|
parts := strings.Split(repoURL, "/")
|
|
if len(parts) >= 5 {
|
|
owner := parts[3]
|
|
repo := strings.TrimSuffix(parts[4], ".git")
|
|
return fmt.Sprintf("io.github.%s/%s", owner, repo)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to get from package.json
|
|
name := getNameFromPackageJSON()
|
|
if name != "" {
|
|
return name
|
|
}
|
|
|
|
// Use current directory name as fallback
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
return fmt.Sprintf("com.example/%s", filepath.Base(cwd))
|
|
}
|
|
|
|
return "com.example/my-mcp-server"
|
|
}
|
|
|
|
func detectDescription() string {
|
|
// Try to get from package.json
|
|
if data, err := os.ReadFile("package.json"); err == nil {
|
|
var pkg map[string]any
|
|
if json.Unmarshal(data, &pkg) == nil {
|
|
if desc, ok := pkg["description"].(string); ok && desc != "" {
|
|
return desc
|
|
}
|
|
}
|
|
}
|
|
|
|
return "An MCP server that provides [describe what your server does]"
|
|
}
|
|
|
|
func detectRepoURL() string {
|
|
// Try git remote
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
|
|
if output, err := cmd.Output(); err == nil {
|
|
url := strings.TrimSpace(string(output))
|
|
// Convert SSH URL to HTTPS if needed
|
|
if strings.HasPrefix(url, "git@github.com:") {
|
|
url = strings.Replace(url, "git@github.com:", "https://github.com/", 1)
|
|
}
|
|
url = strings.TrimSuffix(url, ".git")
|
|
return url
|
|
}
|
|
|
|
// Try package.json repository field
|
|
if data, err := os.ReadFile("package.json"); err == nil {
|
|
var pkg map[string]any
|
|
if json.Unmarshal(data, &pkg) == nil {
|
|
if repo, ok := pkg["repository"].(map[string]any); ok {
|
|
if url, ok := repo["url"].(string); ok {
|
|
return strings.TrimSuffix(url, ".git")
|
|
}
|
|
}
|
|
if repo, ok := pkg["repository"].(string); ok {
|
|
return strings.TrimSuffix(repo, ".git")
|
|
}
|
|
}
|
|
}
|
|
|
|
return "https://github.com/YOUR_USERNAME/YOUR_REPO"
|
|
}
|
|
|
|
func detectPackageType() string {
|
|
// Check for package.json
|
|
if _, err := os.Stat("package.json"); err == nil {
|
|
return model.RegistryTypeNPM
|
|
}
|
|
|
|
// Check for pyproject.toml or setup.py
|
|
if _, err := os.Stat("pyproject.toml"); err == nil {
|
|
return model.RegistryTypePyPI
|
|
}
|
|
if _, err := os.Stat("setup.py"); err == nil {
|
|
return model.RegistryTypePyPI
|
|
}
|
|
|
|
// Check for Dockerfile
|
|
if _, err := os.Stat("Dockerfile"); err == nil {
|
|
return model.RegistryTypeOCI
|
|
}
|
|
|
|
// Default to npm as most common
|
|
return model.RegistryTypeNPM
|
|
}
|
|
|
|
func detectPackageIdentifier(serverName string, packageType string) string {
|
|
switch packageType {
|
|
case model.RegistryTypeNPM:
|
|
// Try to get from package.json
|
|
if data, err := os.ReadFile("package.json"); err == nil {
|
|
var pkg map[string]any
|
|
if json.Unmarshal(data, &pkg) == nil {
|
|
if name, ok := pkg["name"].(string); ok && name != "" {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
// Convert server name to npm package name
|
|
if strings.HasPrefix(serverName, "io.github.") {
|
|
parts := strings.Split(serverName, "/")
|
|
if len(parts) == 2 {
|
|
owner := strings.TrimPrefix(parts[0], "io.github.")
|
|
return fmt.Sprintf("@%s/%s", owner, parts[1])
|
|
}
|
|
}
|
|
return "@your-org/your-package"
|
|
|
|
case model.RegistryTypePyPI:
|
|
// Try to get from pyproject.toml or setup.py
|
|
if data, err := os.ReadFile("pyproject.toml"); err == nil {
|
|
// Simple extraction - could be improved with proper TOML parser
|
|
lines := strings.Split(string(data), "\n")
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "name") && strings.Contains(line, "=") {
|
|
parts := strings.Split(line, "=")
|
|
if len(parts) >= 2 {
|
|
name := strings.Trim(parts[1], " \"'")
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "your-package"
|
|
|
|
case model.RegistryTypeOCI:
|
|
// Use a sensible default
|
|
if strings.Contains(serverName, "/") {
|
|
parts := strings.Split(serverName, "/")
|
|
return parts[len(parts)-1]
|
|
}
|
|
return "your-image"
|
|
|
|
default:
|
|
return "your-package"
|
|
}
|
|
}
|
|
|
|
func createServerJSON(
|
|
name, description, version, repoURL, repoSource,
|
|
packageType, packageIdentifier, packageVersion string,
|
|
envVars []model.KeyValueInput,
|
|
) apiv0.ServerJSON {
|
|
// Determine registry type and base URL
|
|
var registryType, registryBaseURL string
|
|
switch packageType {
|
|
case model.RegistryTypeNPM:
|
|
registryType = model.RegistryTypeNPM
|
|
registryBaseURL = model.RegistryURLNPM
|
|
case model.RegistryTypePyPI:
|
|
registryType = model.RegistryTypePyPI
|
|
registryBaseURL = model.RegistryURLPyPI
|
|
case model.RegistryTypeOCI:
|
|
registryType = model.RegistryTypeOCI
|
|
registryBaseURL = model.RegistryURLDocker
|
|
case "url":
|
|
registryType = "url"
|
|
registryBaseURL = ""
|
|
default:
|
|
registryType = packageType
|
|
registryBaseURL = ""
|
|
}
|
|
|
|
// Create package
|
|
pkg := model.Package{
|
|
RegistryType: registryType,
|
|
RegistryBaseURL: registryBaseURL,
|
|
Identifier: packageIdentifier,
|
|
Version: packageVersion,
|
|
EnvironmentVariables: envVars,
|
|
Transport: model.Transport{
|
|
Type: model.TransportTypeStdio,
|
|
},
|
|
}
|
|
|
|
// Create server structure
|
|
return apiv0.ServerJSON{
|
|
Schema: "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
|
Name: name,
|
|
Description: description,
|
|
Status: model.StatusActive,
|
|
Repository: model.Repository{
|
|
URL: repoURL,
|
|
Source: repoSource,
|
|
},
|
|
Version: version,
|
|
Packages: []model.Package{pkg},
|
|
}
|
|
}
|