(Updated auto-generated PR description from @tadasant / Claude on Feb 22, 2026) ## Motivation and Context Fixes #637 Server lifecycle management (deprecation and yanking) is a standard requirement for package registries. Use cases like rebranding, security vulnerabilities, and end-of-life servers demand the ability to mark servers as deprecated or deleted, with optional messaging to guide consumers toward alternatives. ## Changes ### New API endpoints - **`PATCH /v0/servers/{serverName}/versions/{version}/status`** — Update the lifecycle status of a single server version - **`PATCH /v0/servers/{serverName}/status`** — Update the lifecycle status of all versions of a server in a single transaction Both endpoints accept: ```json { "status": "active|deprecated|deleted", "statusMessage": "Optional message (max 500 chars) explaining the change" } ``` ### New CLI command - **`mcp-publisher status <serverName> [version]`** — Update server lifecycle status from the command line - `--status` (required): `active`, `deprecated`, or `deleted` - `--message`: Optional status message - `--all-versions`: Apply to all versions of the server (replaces the `<version>` argument) ### API response changes `RegistryExtensions` now includes two new fields: - `statusChangedAt` — Timestamp of the last status change - `statusMessage` — Optional free-form message (e.g., deprecation reason, migration guidance) ### List endpoint changes - New `include_deleted` query parameter on `GET /v0/servers` — defaults to `false`, hiding deleted servers from listings - When `updated_since` is provided, `include_deleted` is automatically set to `true` for incremental sync ### Database - Migration `013_add_status_fields.sql`: Adds `status_changed_at` (NOT NULL, initialized from `published_at`) and `status_message` columns to the `servers` table ### Design decisions (from review discussion) - **Dedicated status endpoints** rather than overloading the existing edit endpoint - **Bidirectional status transitions** — all transitions between `active`, `deprecated`, and `deleted` are allowed - **`statusMessage` as free-form text** — no structured `nextName` or `alternativeUrl` fields (per maintainer feedback, to avoid complexity around chaining, trust transfer, etc.) - **Publish permission** grants status change access (not limited to admin/edit) - **Status message auto-cleared** when transitioning back to `active` Good, now I have the full picture. Here's the addendum: ### Consumer-facing API changes The new status fields live inside `_meta["io.modelcontextprotocol.registry/official"]` on every `ServerResponse`. This is the existing `RegistryExtensions` object — two new fields are added alongside the existing ones: **Before:** ```json { "server": { "name": "com.example/my-server", "...": "..." }, "_meta": { "io.modelcontextprotocol.registry/official": { "status": "active", "publishedAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-15T00:00:00Z", "isLatest": true } } } ``` **After:** ```json { "server": { "name": "com.example/my-server", "...": "..." }, "_meta": { "io.modelcontextprotocol.registry/official": { "status": "deprecated", "statusChangedAt": "2026-02-01T12:00:00Z", "statusMessage": "Deprecated in favor of com.example/my-server-v2", "publishedAt": "2025-06-01T00:00:00Z", "updatedAt": "2026-02-01T12:00:00Z", "isLatest": true } } } ``` | New field | Type | Present when | |-----------|------|-------------| | `statusChangedAt` | `string` (RFC3339) | Always — initialized to `publishedAt` for existing records | | `statusMessage` | `string` (max 500 chars) | Only when set — `null`/omitted for active servers (auto-cleared on transition to active) | These fields appear on **every endpoint that returns a `ServerResponse`**: `GET /v0/servers`, `GET /v0/servers/{name}`, `GET /v0/servers/{name}/versions/{version}`, and the new PATCH status endpoints. ### List endpoint filtering `GET /v0/servers` gains an `include_deleted` query parameter: | Scenario | Behavior | |----------|----------| | No `include_deleted` param | Deleted servers hidden (default `false`) | | `include_deleted=true` | Deleted servers included in results | | `updated_since` provided | `include_deleted` forced to `true` for incremental sync | ## How Has This Been Tested? - Unit tests for status handler (`status_test.go`) covering status transitions, permission checks, validation errors, and the all-versions endpoint - Unit tests for the CLI command (`status_test.go`) - Integration with existing server/edit handler tests - Database layer tests for the new status update and query methods - Tested locally with `make dev-compose` ## Types of changes - [x] New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Open items from review - [ ] `include_deleted` silent override: When `updated_since` is provided and `include_deleted=false` is explicitly passed, should return a 400 error rather than silently overriding ([thread](https://github.com/modelcontextprotocol/registry/pull/893#discussion_r2100879360)) — agreed upon but may not yet be implemented
311 lines
8.7 KiB
Go
311 lines
8.7 KiB
Go
package commands_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
|
|
)
|
|
|
|
func TestStatusCommand_Validation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
expectError bool
|
|
errorSubstr string
|
|
}{
|
|
{
|
|
name: "missing --status flag",
|
|
args: []string{"io.github.user/my-server", "1.0.0"},
|
|
expectError: true,
|
|
errorSubstr: "--status flag is required",
|
|
},
|
|
{
|
|
name: "invalid status value",
|
|
args: []string{"--status", "invalid", "io.github.user/my-server", "1.0.0"},
|
|
expectError: true,
|
|
errorSubstr: "invalid status 'invalid'",
|
|
},
|
|
{
|
|
name: "missing server name",
|
|
args: []string{"--status", "deprecated"},
|
|
expectError: true,
|
|
errorSubstr: "server name is required",
|
|
},
|
|
{
|
|
name: "missing version without --all-versions",
|
|
args: []string{"--status", "deprecated", "io.github.user/my-server"},
|
|
expectError: true,
|
|
errorSubstr: "version is required unless --all-versions",
|
|
},
|
|
{
|
|
name: "valid args passes validation",
|
|
args: []string{"--status", "deprecated", "io.github.user/my-server", "1.0.0"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid args with --all-versions passes validation",
|
|
args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server"},
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := commands.StatusCommand(tt.args)
|
|
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.errorSubstr) {
|
|
t.Errorf("Expected error containing '%s', got: %v", tt.errorSubstr, err)
|
|
}
|
|
} else if err != nil {
|
|
// For valid args, we expect it to pass validation
|
|
// It may fail later at auth or API level, which is acceptable
|
|
// Just check it's not a validation error
|
|
if strings.Contains(err.Error(), "invalid status") ||
|
|
strings.Contains(err.Error(), "server name is required") ||
|
|
strings.Contains(err.Error(), "version is required unless") ||
|
|
strings.Contains(err.Error(), "--status flag is required") {
|
|
t.Errorf("Validation failed unexpectedly: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCommand_ServerNameValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
serverName string
|
|
}{
|
|
{
|
|
name: "valid github server name",
|
|
serverName: "io.github.user/my-server",
|
|
},
|
|
{
|
|
name: "valid domain server name",
|
|
serverName: "com.example/my-server",
|
|
},
|
|
{
|
|
name: "server name with dashes",
|
|
serverName: "io.github.user/my-cool-server",
|
|
},
|
|
{
|
|
name: "server name with underscores",
|
|
serverName: "io.github.user/my_server",
|
|
},
|
|
{
|
|
name: "server name with dots",
|
|
serverName: "io.github.user/my.server",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
args := []string{"--status", "deprecated", tt.serverName, "1.0.0"}
|
|
err := commands.StatusCommand(args)
|
|
|
|
// Should pass validation (server name format is not validated by CLI)
|
|
if err != nil && strings.Contains(err.Error(), "server name is required") {
|
|
t.Errorf("Server name '%s' was rejected", tt.serverName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCommand_VersionValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
}{
|
|
{
|
|
name: "semver version",
|
|
version: "1.0.0",
|
|
},
|
|
{
|
|
name: "semver with patch",
|
|
version: "1.2.3",
|
|
},
|
|
{
|
|
name: "semver with prerelease",
|
|
version: "1.0.0-alpha",
|
|
},
|
|
{
|
|
name: "semver with build metadata",
|
|
version: "1.0.0+20130313144700",
|
|
},
|
|
{
|
|
name: "semver with prerelease and build",
|
|
version: "1.0.0-beta.1+exp.sha.5114f85",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
args := []string{"--status", "deprecated", "io.github.user/my-server", tt.version}
|
|
err := commands.StatusCommand(args)
|
|
|
|
// Should pass validation (version format is not validated by CLI)
|
|
if err != nil && strings.Contains(err.Error(), "version is required") {
|
|
t.Errorf("Version '%s' was rejected", tt.version)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCommand_AllVersionsFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
expectError bool
|
|
errorSubstr string
|
|
}{
|
|
{
|
|
name: "all-versions without version arg passes validation",
|
|
args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "all-versions with extra version arg still works",
|
|
args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server", "1.0.0"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "missing server name with all-versions",
|
|
args: []string{"--status", "deprecated", "--all-versions"},
|
|
expectError: true,
|
|
errorSubstr: "server name is required",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := commands.StatusCommand(tt.args)
|
|
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.errorSubstr) {
|
|
t.Errorf("Expected error containing '%s', got: %v", tt.errorSubstr, err)
|
|
}
|
|
} else if err != nil {
|
|
// Should pass validation
|
|
// Just check it's not a validation error
|
|
if strings.Contains(err.Error(), "invalid status") ||
|
|
strings.Contains(err.Error(), "server name is required") ||
|
|
strings.Contains(err.Error(), "version is required unless") ||
|
|
strings.Contains(err.Error(), "--status flag is required") {
|
|
t.Errorf("Validation failed unexpectedly: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCommand_FlagCombinations(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{
|
|
name: "status with message",
|
|
args: []string{"--status", "deprecated", "--message", "Please upgrade to v2", "io.github.user/my-server", "1.0.0"},
|
|
},
|
|
{
|
|
name: "all-versions with message",
|
|
args: []string{"--status", "deprecated", "--all-versions", "--message", "All versions deprecated", "io.github.user/my-server"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := commands.StatusCommand(tt.args)
|
|
// All these should pass CLI validation
|
|
// They may fail at auth or API level which is acceptable
|
|
if err != nil {
|
|
// Just check it's not a validation error we can detect
|
|
if strings.Contains(err.Error(), "invalid status") ||
|
|
strings.Contains(err.Error(), "server name is required") ||
|
|
strings.Contains(err.Error(), "version is required unless") ||
|
|
strings.Contains(err.Error(), "--status flag is required") {
|
|
t.Errorf("Validation failed unexpectedly: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCommand_MissingStatus(t *testing.T) {
|
|
// Test various ways status flag can be missing
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{
|
|
name: "no status flag at all",
|
|
args: []string{"io.github.user/my-server", "1.0.0"},
|
|
},
|
|
{
|
|
name: "empty status value",
|
|
args: []string{"--status", "", "io.github.user/my-server", "1.0.0"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := commands.StatusCommand(tt.args)
|
|
|
|
if err == nil {
|
|
t.Errorf("Expected error for missing status but got none")
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), "--status flag is required") {
|
|
t.Errorf("Expected '--status flag is required' error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCommand_YesFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{
|
|
name: "all-versions with --yes flag",
|
|
args: []string{"--status", "deprecated", "--all-versions", "--yes", "io.github.user/my-server"},
|
|
},
|
|
{
|
|
name: "all-versions with -y shorthand",
|
|
args: []string{"--status", "deprecated", "--all-versions", "-y", "io.github.user/my-server"},
|
|
},
|
|
{
|
|
name: "yes flag with single version (flag accepted but not used)",
|
|
args: []string{"--status", "deprecated", "--yes", "io.github.user/my-server", "1.0.0"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := commands.StatusCommand(tt.args)
|
|
// All these should pass CLI validation
|
|
// They may fail at auth or API level which is acceptable
|
|
if err != nil {
|
|
// Just check it's not a validation error
|
|
if strings.Contains(err.Error(), "invalid status") ||
|
|
strings.Contains(err.Error(), "server name is required") ||
|
|
strings.Contains(err.Error(), "version is required unless") ||
|
|
strings.Contains(err.Error(), "--status flag is required") ||
|
|
strings.Contains(err.Error(), "flag provided but not defined") {
|
|
t.Errorf("Validation failed unexpectedly: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|