Skip to content
Open
246 changes: 246 additions & 0 deletions cmd/publisher/commands/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package commands

import (
"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"`
AlternativeURL *string `json:"alternativeUrl,omitempty"`
NewName *string `json:"newName,omitempty"`
}

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

func StatusCommand(args []string) error {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure about calling this command status. It's also used for setting e.g. newName, which isn't necessarily associated with a status change. Can we make it more generic, like mcp-publisher update, mcp-publisher patch or something similar?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am happy to accommodate this change. Reasons that I thought are:

  1. Domain clarity: Status describes the domain action
    (lifecycle management), while update/patch describes
    the technical operation. Domain-focused naming is
    better for CLI UX.
  2. Separation of concerns: The project already has clear separation:
  • publish → add new version
  • edit → update server configuration
  • status → manage lifecycle state
  1. Constraint coupling: Since newName is ONLY valid with deprecated/yanked status, it's fundamentally part of the status workflow, not independent metadata.

  2. User mental model: When users want to deprecate/yank a server, they'll naturally think "status". When they want to change description/remotes, they'll think "edit".

Happy to hear your thoughts on this.

Copy link
Member

Choose a reason for hiding this comment

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

Fair points -- I think I'm definitely OK with keeping it status if we end up agreeing to drop newName from this work.

// Parse command flags
fs := flag.NewFlagSet("status", flag.ExitOnError)
status := fs.String("status", "", "New status: active, deprecated, or yanked (required)")
message := fs.String("message", "", "Optional status message explaining the change")
alternativeURL := fs.String("alternative-url", "", "Optional URL to alternative/replacement server")
newName := fs.String("new-name", "", "Optional new server name when server has been renamed")
allVersions := fs.Bool("all-versions", false, "Apply status change to all versions of the server")

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

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

// Validate status value
validStatuses := map[string]bool{"active": true, "deprecated": true, "yanked": true}
if !validStatuses[*status] {
return fmt.Errorf("invalid status '%s'. Must be one of: active, deprecated, yanked", *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 <server-name> [version] --status <active|deprecated|yanked> [flags]")
}

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 <server-name> <version> --status <active|deprecated|yanked> [flags]")
}
version = remainingArgs[1]
}

// Validate new-name parameter constraints
if *newName != "" {
// Validation: new-name requires deprecated or yanked status
if *status != "deprecated" && *status != "yanked" {
return errors.New("--new-name can only be used with --status deprecated or --status yanked")
}
// Validation: new-name requires --all-versions flag
if !*allVersions {
return errors.New("--new-name requires --all-versions flag")
}
}

// 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, *alternativeURL, *newName, token)
}
return updateVersionStatus(registryURL, serverName, version, *status, *message, *alternativeURL, *newName, token)
}

func updateVersionStatus(registryURL, serverName, version, status, statusMessage, alternativeURL, newName, token string) error {
_, _ = fmt.Fprintf(os.Stdout, "Updating %s version %s to status: %s\n", serverName, version, status)

if err := updateServerStatus(registryURL, serverName, version, status, statusMessage, alternativeURL, newName, 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, alternativeURL, newName, token string) error {
_, _ = fmt.Fprintf(os.Stdout, "Updating all versions of %s to status: %s\n", serverName, status)

if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}

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

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, alternativeURL, newName, token string) error {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}

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

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
}
Loading
Loading