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>
162 lines
5.7 KiB
Go
162 lines
5.7 KiB
Go
package v0
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/google/uuid"
|
|
"github.com/modelcontextprotocol/registry/internal/database"
|
|
"github.com/modelcontextprotocol/registry/internal/service"
|
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
)
|
|
|
|
// ListServersInput represents the input for listing servers
|
|
type ListServersInput struct {
|
|
Cursor string `query:"cursor" doc:"Pagination cursor (UUID)" format:"uuid" required:"false" example:"550e8400-e29b-41d4-a716-446655440000"`
|
|
Limit int `query:"limit" doc:"Number of items per page" default:"30" minimum:"1" maximum:"100" example:"50"`
|
|
UpdatedSince string `query:"updated_since" doc:"Filter servers updated since timestamp (RFC3339 datetime)" required:"false" example:"2025-08-07T13:15:04.280Z"`
|
|
Search string `query:"search" doc:"Search servers by name (substring match)" required:"false" example:"filesystem"`
|
|
Version string `query:"version" doc:"Filter by version ('latest' for latest version, or an exact version like '1.2.3')" required:"false" example:"latest"`
|
|
}
|
|
|
|
// ServerDetailInput represents the input for getting server details
|
|
type ServerDetailInput struct {
|
|
ServerID string `path:"server_id" doc:"Server ID (UUID)" format:"uuid"`
|
|
Version string `query:"version" doc:"Specific version to retrieve (e.g., '1.0.0'). If not specified, returns latest version." required:"false" example:"1.0.0"`
|
|
}
|
|
|
|
// ServerVersionsInput represents the input for listing all versions of a server
|
|
type ServerVersionsInput struct {
|
|
ServerID string `path:"server_id" doc:"Server ID (UUID)" format:"uuid"`
|
|
}
|
|
|
|
// RegisterServersEndpoints registers all server-related endpoints
|
|
func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
|
|
// List servers endpoint
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "list-servers",
|
|
Method: http.MethodGet,
|
|
Path: "/v0/servers",
|
|
Summary: "List MCP servers",
|
|
Description: "Get a paginated list of MCP servers from the registry",
|
|
Tags: []string{"servers"},
|
|
}, func(_ context.Context, input *ListServersInput) (*Response[apiv0.ServerListResponse], error) {
|
|
// Validate cursor if provided
|
|
if input.Cursor != "" {
|
|
_, err := uuid.Parse(input.Cursor)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest("Invalid cursor parameter")
|
|
}
|
|
}
|
|
|
|
// Build filter from input parameters
|
|
filter := &database.ServerFilter{}
|
|
|
|
// Parse updated_since parameter
|
|
if input.UpdatedSince != "" {
|
|
// Parse RFC3339 format
|
|
if updatedTime, err := time.Parse(time.RFC3339, input.UpdatedSince); err == nil {
|
|
filter.UpdatedSince = &updatedTime
|
|
} else {
|
|
return nil, huma.Error400BadRequest("Invalid updated_since format: expected RFC3339 timestamp (e.g., 2025-08-07T13:15:04.280Z)")
|
|
}
|
|
}
|
|
|
|
// Handle search parameter
|
|
if input.Search != "" {
|
|
filter.SubstringName = &input.Search
|
|
}
|
|
|
|
// Handle version parameter
|
|
if input.Version != "" {
|
|
if input.Version == "latest" {
|
|
// Special case: filter for latest versions
|
|
isLatest := true
|
|
filter.IsLatest = &isLatest
|
|
} else {
|
|
// Future: exact version matching
|
|
filter.Version = &input.Version
|
|
}
|
|
}
|
|
|
|
// Get paginated results with filtering
|
|
servers, nextCursor, err := registry.List(filter, input.Cursor, input.Limit)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("Failed to get registry list", err)
|
|
}
|
|
|
|
return &Response[apiv0.ServerListResponse]{
|
|
Body: apiv0.ServerListResponse{
|
|
Servers: servers,
|
|
Metadata: apiv0.Metadata{
|
|
NextCursor: nextCursor,
|
|
Count: len(servers),
|
|
},
|
|
},
|
|
}, nil
|
|
})
|
|
|
|
// Get server details endpoint
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-server",
|
|
Method: http.MethodGet,
|
|
Path: "/v0/servers/{server_id}",
|
|
Summary: "Get MCP server details",
|
|
Description: "Get detailed information about a specific MCP server. Returns the latest version by default, or a specific version if the 'version' query parameter is provided.",
|
|
Tags: []string{"servers"},
|
|
}, func(_ context.Context, input *ServerDetailInput) (*Response[apiv0.ServerJSON], error) {
|
|
// Get the server details from the registry service
|
|
var serverDetail *apiv0.ServerJSON
|
|
var err error
|
|
|
|
if input.Version != "" {
|
|
// Get specific version by server_id and version
|
|
serverDetail, err = registry.GetByServerIDAndVersion(input.ServerID, input.Version)
|
|
} else {
|
|
// Get latest version by server_id
|
|
serverDetail, err = registry.GetByServerID(input.ServerID)
|
|
}
|
|
|
|
if err != nil {
|
|
if err.Error() == "record not found" || errors.Is(err, database.ErrNotFound) {
|
|
return nil, huma.Error404NotFound("Server not found")
|
|
}
|
|
return nil, huma.Error500InternalServerError("Failed to get server details", err)
|
|
}
|
|
|
|
return &Response[apiv0.ServerJSON]{
|
|
Body: *serverDetail,
|
|
}, nil
|
|
})
|
|
|
|
// Get server versions endpoint
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-server-versions",
|
|
Method: http.MethodGet,
|
|
Path: "/v0/servers/{server_id}/versions",
|
|
Summary: "Get all versions of an MCP server",
|
|
Description: "Get all available versions for a specific MCP server",
|
|
Tags: []string{"servers"},
|
|
}, func(_ context.Context, input *ServerVersionsInput) (*Response[apiv0.ServerListResponse], error) {
|
|
// Get all versions for this server
|
|
servers, err := registry.GetAllVersionsByServerID(input.ServerID)
|
|
if err != nil {
|
|
if err.Error() == "record not found" {
|
|
return nil, huma.Error404NotFound("Server not found")
|
|
}
|
|
return nil, huma.Error500InternalServerError("Failed to get server versions", err)
|
|
}
|
|
|
|
return &Response[apiv0.ServerListResponse]{
|
|
Body: apiv0.ServerListResponse{
|
|
Servers: servers,
|
|
Metadata: apiv0.Metadata{
|
|
Count: len(servers),
|
|
},
|
|
},
|
|
}, nil
|
|
})
|
|
}
|