## Summary Fixes #663 - **Security fix**: `mcp-publisher login` no longer writes `.mcpregistry_github_token` and `.mcpregistry_registry_token` to the current working directory. GitHub tokens are kept in memory during the login→exchange flow and cleared immediately after. - **Token location**: Unified token moved from `~/.mcp_publisher_token` to `~/.config/mcp-publisher/token.json` (dir 0700, file 0600), following the convention used by `gh`, `docker`, and `kubectl`. - **Dead code removed**: `NeedsLogin()` was on the `Provider` interface but never called — removed from interface and all 4 implementations. `forceLogin` field also removed. - **Auth tests added**: 11 new tests for `GitHubATProvider` (package previously had 0 tests). - **Test isolation**: `SetupTestToken` now uses `t.TempDir()` + `t.Setenv("HOME", ...)` instead of writing to real `$HOME`. ### Breaking change Users must re-login after upgrading (`mcp-publisher login <method>`). Existing tokens at `~/.mcp_publisher_token` are not auto-migrated. `logout` cleans up both old and new locations. ## Test plan - [x] `make check` passes (lint, unit tests, integration tests) - [x] `mcp-publisher login none` stores token at `~/.config/mcp-publisher/token.json` - [x] No `.mcpregistry_*` files created in cwd after login (with `--token`, `MCP_GITHUB_TOKEN`, or device flow) - [x] `mcp-publisher publish` reads token from new location - [x] `mcp-publisher validate` reads registry URL from new location - [x] `mcp-publisher logout` cleans up new location, legacy `~/.mcp_publisher_token`, and cwd `.mcpregistry_*` files - [x] `mcp-publisher logout` when not logged in shows "Not logged in" - [x] File permissions: dir 0700, file 0600 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
373 lines
11 KiB
Go
373 lines
11 KiB
Go
package commands
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"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
|
|
tokenPath, err := tokenFilePath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tokenData, err := os.ReadFile(tokenPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return notAuthenticatedError()
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|