Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
17b1715
deprecated-with-yanked: first take with possibility of renaming
pree-dew Jan 13, 2026
4ad7275
some minor fixes for comment and instead of checking edit permission …
pree-dew Jan 16, 2026
fcb4e19
fix linting and formating issues
pree-dew Jan 16, 2026
c0e9eb2
fix linting and formating issues
pree-dew Jan 16, 2026
742660b
fix linting and formating issues
pree-dew Jan 16, 2026
9cbb049
fix merge conflicts
pree-dew Jan 16, 2026
92020ef
remove unused import
pree-dew Jan 16, 2026
c1a025b
fix gofmt issues
pree-dew Jan 16, 2026
d81d440
add bulk endpoint for all versions of server, update cli, include fil…
pree-dew Jan 21, 2026
c5bf5c8
fix lint issues
pree-dew Jan 21, 2026
702ad43
apply de morgan's law
pree-dew Jan 21, 2026
491c664
make validation logic simpler
pree-dew Jan 21, 2026
34c0cf8
reduce the cyclomatic complexity
pree-dew Jan 21, 2026
9869bc6
change yank to delete, remove alternative url and new name
pree-dew Jan 28, 2026
8ae0ddf
fix go fmt issues
pree-dew Jan 28, 2026
8852ccd
Merge branch 'main' into deprecated-with-yanked
pree-dew Jan 28, 2026
7d09b36
update documentation
pree-dew Jan 30, 2026
f0ee46c
update branch
pree-dew Feb 4, 2026
9e4566f
handle ux issues, validation of remote urls and include delete fixes
pree-dew Feb 24, 2026
50590fb
fix lint errors
pree-dew Feb 24, 2026
7a42fd4
fix lint error
pree-dew Feb 24, 2026
7884574
fix lint error
pree-dew Feb 24, 2026
1ee1521
improve documentation, include_delete addition in fetch functions to …
pree-dew Feb 27, 2026
dc45fbe
improve file formatting
pree-dew Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/publisher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ make dev-compose # Start local registry

### Commands
- **`init`** - Generate server.json templates with auto-detection
- **`login`** - Handle authentication (github, dns, http, none)
- **`login`** - Handle authentication (github, dns, http, none)
- **`publish`** - Validate and upload servers to registry
- **`status`** - Update server lifecycle status (active, deprecated, deleted)
- **`logout`** - Clear stored credentials

### Authentication Providers
Expand Down
375 changes: 375 additions & 0 deletions cmd/publisher/commands/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,375 @@
package commands

import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)

// StatusUpdateRequest represents the request body for status update endpoints
type StatusUpdateRequest struct {
Status string `json:"status"`
StatusMessage *string `json:"statusMessage,omitempty"`
}

// AllVersionsStatusResponse represents the response from the all-versions status endpoint
type AllVersionsStatusResponse struct {
UpdatedCount int `json:"updatedCount"`
}

// VersionInfo holds version and status for display
type VersionInfo struct {
Version string
Status string
}

// ServerResponseMeta represents the _meta field in API responses
type ServerResponseMeta struct {
Official *struct {
Status string `json:"status"`
} `json:"io.modelcontextprotocol.registry/official,omitempty"`
}

// SingleServerResponse represents the response from a single server version endpoint
type SingleServerResponse struct {
Server struct {
Version string `json:"version"`
} `json:"server"`
Meta ServerResponseMeta `json:"_meta"`
}

// ServerListResponse represents the response from the versions list endpoint
type ServerListResponse struct {
Servers []SingleServerResponse `json:"servers"`
}

func StatusCommand(args []string) error {
// Parse command flags
fs := flag.NewFlagSet("status", flag.ExitOnError)
status := fs.String("status", "", "New status: active, deprecated, or deleted (required)")
message := fs.String("message", "", "Optional status message explaining the change")
allVersions := fs.Bool("all-versions", false, "Apply status change to all versions of the server")
yes := fs.Bool("yes", false, "Skip confirmation prompt for bulk operations")
fs.BoolVar(yes, "y", false, "Skip confirmation prompt for bulk operations (shorthand)")

if err := fs.Parse(args); err != nil {
return err
}

// Validate required arguments
if *status == "" {
return errors.New("--status flag is required (active, deprecated, or deleted)")
}

// Validate status value
validStatuses := map[string]bool{"active": true, "deprecated": true, "deleted": true}
if !validStatuses[*status] {
return fmt.Errorf("invalid status '%s'. Must be one of: active, deprecated, deleted", *status)
}

// Get server name from positional args
remainingArgs := fs.Args()
if len(remainingArgs) < 1 {
return errors.New("server name is required\n\nUsage: mcp-publisher status --status <active|deprecated|deleted> [flags] <server-name> [version]")
}

serverName := remainingArgs[0]
var version string

// Get version if provided (required unless --all-versions is set)
if !*allVersions {
if len(remainingArgs) < 2 {
return errors.New("version is required unless --all-versions flag is set\n\nUsage: mcp-publisher status --status <active|deprecated|deleted> [flags] <server-name> <version>")
}
version = remainingArgs[1]
}

// Load saved token
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}

tokenPath := filepath.Join(homeDir, TokenFileName)
tokenData, err := os.ReadFile(tokenPath)
if err != nil {
if os.IsNotExist(err) {
return errors.New("not authenticated. Run 'mcp-publisher login <method>' first")
}
return fmt.Errorf("failed to read token: %w", err)
}

var tokenInfo map[string]string
if err := json.Unmarshal(tokenData, &tokenInfo); err != nil {
return fmt.Errorf("invalid token data: %w", err)
}

token := tokenInfo["token"]
registryURL := tokenInfo["registry"]
if registryURL == "" {
registryURL = DefaultRegistryURL
}

// Update status
if *allVersions {
return updateAllVersionsStatus(registryURL, serverName, *status, *message, token, *yes)
}
return updateVersionStatus(registryURL, serverName, version, *status, *message, token)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking; let's probably take this as a fast-follow (or just file an issue if you don't think you'll be able to get to it soon) --

Two UX concerns with the CLI:

1. No confirmation on --all-versions

Running mcp-publisher status --status deleted --all-versions io.github.me/my-server fires immediately with no safety net. This is a bulk destructive operation; let have it prompt for confirmation (e.g., This will mark all N versions of io.github.me/my-server as deleted. Continue? [y/N]), with a --yes/-y flag to skip it for scripting/CI.

I don't think we need confirmation for single-version updates; those are targeted enough.

2. No "from → to" in the output

Currently the output only says what we're changing to:

Updating my-server version 1.0.0 to status: deleted
✓ Successfully updated status

It should show the previous status so the user can easily undo a mistake:

Updating my-server version 1.0.0: active → deleted
✓ Successfully updated status

We should do this for both the single-version changes and the bulk changes.


func updateVersionStatus(registryURL, serverName, version, status, statusMessage, token string) error {
// Fetch current status to show "from → to"
currentStatus, err := fetchVersionStatus(registryURL, serverName, version, token)
if err != nil {
return fmt.Errorf("failed to fetch current status: %w", err)
}

_, _ = fmt.Fprintf(os.Stdout, "Updating %s version %s: %s → %s\n", serverName, version, currentStatus, status)

if err := updateServerStatus(registryURL, serverName, version, status, statusMessage, token); err != nil {
return fmt.Errorf("failed to update status: %w", err)
}

_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully updated status")
return nil
}

func updateAllVersionsStatus(registryURL, serverName, status, statusMessage, token string, skipConfirm bool) error {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}

// Fetch all versions to show current statuses and get count for confirmation
versions, err := fetchAllVersionsStatus(registryURL, serverName, token)
if err != nil {
return fmt.Errorf("failed to fetch current versions: %w", err)
}

if len(versions) == 0 {
return errors.New("no versions found for this server")
}

// Show what will be updated
_, _ = fmt.Fprintf(os.Stdout, "This will update %d version(s) of %s:\n", len(versions), serverName)
for _, v := range versions {
_, _ = fmt.Fprintf(os.Stdout, " %s: %s → %s\n", v.Version, v.Status, status)
}

// Prompt for confirmation unless -y/--yes was provided
if !skipConfirm {
_, _ = fmt.Fprint(os.Stdout, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
return errors.New("operation cancelled")
}
}

// Build the request body
requestBody := StatusUpdateRequest{
Status: status,
}
if statusMessage != "" {
requestBody.StatusMessage = &statusMessage
}

jsonData, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("error serializing request: %w", err)
}

// URL encode the server name
encodedServerName := url.PathEscape(serverName)
statusURL := registryURL + "v0/servers/" + encodedServerName + "/status"

req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, statusURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}

// Parse response to get updated count
var response AllVersionsStatusResponse
if err := json.Unmarshal(body, &response); err != nil {
// If we can't parse the response, just report success
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully updated all versions")
return nil
}

_, _ = fmt.Fprintf(os.Stdout, "✓ Successfully updated %d version(s)\n", response.UpdatedCount)
return nil
}

func updateServerStatus(registryURL, serverName, version, status, statusMessage, token string) error {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}

// Build the request body
requestBody := StatusUpdateRequest{
Status: status,
}
if statusMessage != "" {
requestBody.StatusMessage = &statusMessage
}

jsonData, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("error serializing request: %w", err)
}

// URL encode the server name and version
encodedServerName := url.PathEscape(serverName)
encodedVersion := url.PathEscape(version)
statusURL := registryURL + "v0/servers/" + encodedServerName + "/versions/" + encodedVersion + "/status"

req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, statusURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}

return nil
}

func fetchVersionStatus(registryURL, serverName, version, token string) (string, error) {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}

encodedServerName := url.PathEscape(serverName)
encodedVersion := url.PathEscape(version)
fetchURL := registryURL + "v0/servers/" + encodedServerName + "/versions/" + encodedVersion + "?include_deleted=true"

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fetchURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}

// Parse the response to extract status
var response SingleServerResponse
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("error parsing response: %w", err)
}

if response.Meta.Official == nil {
return "", errors.New("server response missing status information")
}

return response.Meta.Official.Status, nil
}

func fetchAllVersionsStatus(registryURL, serverName, token string) ([]VersionInfo, error) {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}

encodedServerName := url.PathEscape(serverName)
fetchURL := registryURL + "v0/servers/" + encodedServerName + "/versions?include_deleted=true"

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fetchURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}

var response ServerListResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("error parsing response: %w", err)
}

var versions []VersionInfo
for _, s := range response.Servers {
status := "unknown"
if s.Meta.Official != nil {
status = s.Meta.Official.Status
}
versions = append(versions, VersionInfo{
Version: s.Server.Version,
Status: status,
})
}

return versions, nil
}
Loading
Loading