Tidy up command structure for MCP Publisher (#324)

## Summary
- Improved command structure and organization for the MCP Publisher CLI
- Enhanced readability and maintainability of publisher commands
- Cleaned up publish and edit handler logic

This builds on the previous cleanup work to further streamline the
publisher interface.

This also works as part of
https://github.com/modelcontextprotocol/registry/issues/89
This commit is contained in:
adam jones 2025-08-29 12:44:49 +01:00 • committed by GitHub
commit b8106340bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 883 additions and 884 deletions

View file

@ -0,0 +1,319 @@
package commands
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/modelcontextprotocol/registry/pkg/model"
)
const (
RegistryTypeNPM = "npm"
RegistryTypePyPi = "pypi"
RegistryTypeDocker = "docker"
)
func InitCommand() error {
// Check if server.json already exists
if _, err := os.Stat("server.json"); err == nil {
return errors.New("server.json already exists")
}
// Try to detect values from environment
name := detectServerName()
description := detectDescription()
version := "1.0.0"
repoURL := detectRepoURL()
repoSource := "github"
if repoURL != "" && !strings.Contains(repoURL, "github.com") {
if strings.Contains(repoURL, "gitlab.com") {
repoSource = "gitlab"
} else {
repoSource = "git"
}
}
packageType := detectPackageType()
packageIdentifier := detectPackageIdentifier(name, packageType)
// Create example environment variables
envVars := []model.KeyValueInput{
{
Name: "YOUR_API_KEY",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Your API key for the service",
IsRequired: true,
IsSecret: true,
Format: model.FormatString,
},
},
},
}
// Create the server structure
server := createServerJSON(
name, description, version, repoURL, repoSource,
packageType, packageIdentifier, version, envVars,
)
// Write to file
jsonData, err := json.MarshalIndent(server, "", " ")
if err != nil {
return fmt.Errorf("error marshaling JSON: %w", err)
}
err = os.WriteFile("server.json", jsonData, 0600)
if err != nil {
return fmt.Errorf("error writing file: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "Created server.json")
_, _ = fmt.Fprintln(os.Stdout, "\nEdit server.json to update:")
_, _ = fmt.Fprintln(os.Stdout, " • Server name and description")
_, _ = fmt.Fprintln(os.Stdout, " • Package details")
_, _ = fmt.Fprintln(os.Stdout, " • Environment variables")
_, _ = fmt.Fprintln(os.Stdout, "\nThen publish with:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github # or your preferred auth method")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish")
return nil
}
func getNameFromPackageJSON() string {
data, err := os.ReadFile("package.json")
if err != nil {
return ""
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return ""
}
name, ok := pkg["name"].(string)
if !ok || name == "" {
return ""
}
// Convert npm package name to MCP server name
// @org/package -> io.npm.org/package
if strings.HasPrefix(name, "@") {
parts := strings.Split(name[1:], "/")
if len(parts) == 2 {
return fmt.Sprintf("io.github.%s/%s", parts[0], parts[1])
}
}
return fmt.Sprintf("io.github.<your-username>/%s", name)
}
func detectServerName() string {
// Try to get from git remote
repoURL := detectRepoURL()
if repoURL != "" {
// Extract owner/repo from GitHub URL
if strings.Contains(repoURL, "github.com") {
parts := strings.Split(repoURL, "/")
if len(parts) >= 5 {
owner := parts[3]
repo := strings.TrimSuffix(parts[4], ".git")
return fmt.Sprintf("io.github.%s/%s", owner, repo)
}
}
}
// Try to get from package.json
name := getNameFromPackageJSON()
if name != "" {
return name
}
// Use current directory name as fallback
if cwd, err := os.Getwd(); err == nil {
return fmt.Sprintf("com.example/%s", filepath.Base(cwd))
}
return "com.example/my-mcp-server"
}
func detectDescription() string {
// Try to get from package.json
if data, err := os.ReadFile("package.json"); err == nil {
var pkg map[string]any
if json.Unmarshal(data, &pkg) == nil {
if desc, ok := pkg["description"].(string); ok && desc != "" {
return desc
}
}
}
return "An MCP server that provides [describe what your server does]"
}
func detectRepoURL() string {
// Try git remote
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
if output, err := cmd.Output(); err == nil {
url := strings.TrimSpace(string(output))
// Convert SSH URL to HTTPS if needed
if strings.HasPrefix(url, "git@github.com:") {
url = strings.Replace(url, "git@github.com:", "https://github.com/", 1)
}
url = strings.TrimSuffix(url, ".git")
return url
}
// Try package.json repository field
if data, err := os.ReadFile("package.json"); err == nil {
var pkg map[string]any
if json.Unmarshal(data, &pkg) == nil {
if repo, ok := pkg["repository"].(map[string]any); ok {
if url, ok := repo["url"].(string); ok {
return strings.TrimSuffix(url, ".git")
}
}
if repo, ok := pkg["repository"].(string); ok {
return strings.TrimSuffix(repo, ".git")
}
}
}
return "https://github.com/YOUR_USERNAME/YOUR_REPO"
}
func detectPackageType() string {
// Check for package.json
if _, err := os.Stat("package.json"); err == nil {
return RegistryTypeNPM
}
// Check for pyproject.toml or setup.py
if _, err := os.Stat("pyproject.toml"); err == nil {
return RegistryTypePyPi
}
if _, err := os.Stat("setup.py"); err == nil {
return RegistryTypePyPi
}
// Check for Dockerfile
if _, err := os.Stat("Dockerfile"); err == nil {
return RegistryTypeDocker
}
// Default to npm as most common
return RegistryTypeNPM
}
func detectPackageIdentifier(serverName string, packageType string) string {
switch packageType {
case RegistryTypeNPM:
// Try to get from package.json
if data, err := os.ReadFile("package.json"); err == nil {
var pkg map[string]any
if json.Unmarshal(data, &pkg) == nil {
if name, ok := pkg["name"].(string); ok && name != "" {
return name
}
}
}
// Convert server name to npm package name
if strings.HasPrefix(serverName, "io.github.") {
parts := strings.Split(serverName, "/")
if len(parts) == 2 {
owner := strings.TrimPrefix(parts[0], "io.github.")
return fmt.Sprintf("@%s/%s", owner, parts[1])
}
}
return "@your-org/your-package"
case "pypi":
// Try to get from pyproject.toml or setup.py
if data, err := os.ReadFile("pyproject.toml"); err == nil {
// Simple extraction - could be improved with proper TOML parser
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "name") && strings.Contains(line, "=") {
parts := strings.Split(line, "=")
if len(parts) >= 2 {
name := strings.Trim(parts[1], " \"'")
if name != "" {
return name
}
}
}
}
}
return "your-package"
case "docker":
// Use a sensible default
if strings.Contains(serverName, "/") {
parts := strings.Split(serverName, "/")
return parts[len(parts)-1]
}
return "your-image"
default:
return "your-package"
}
}
func createServerJSON(
name, description, version, repoURL, repoSource,
packageType, packageIdentifier, packageVersion string,
envVars []model.KeyValueInput,
) model.ServerJSON {
// Determine registry type and base URL
var registryType, registryBaseURL string
switch packageType {
case RegistryTypeNPM:
registryType = RegistryTypeNPM
registryBaseURL = "https://registry.npmjs.org"
case RegistryTypePyPi:
registryType = RegistryTypePyPi
registryBaseURL = "https://pypi.org"
case RegistryTypeDocker:
registryType = "docker-hub"
registryBaseURL = "https://docker.io"
case "url":
registryType = "url"
registryBaseURL = ""
default:
registryType = packageType
registryBaseURL = ""
}
// Create package
pkg := model.Package{
RegistryType: registryType,
RegistryBaseURL: registryBaseURL,
Identifier: packageIdentifier,
Version: packageVersion,
EnvironmentVariables: envVars,
}
// Create server structure
return model.ServerJSON{
Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
Name: name,
Description: description,
Status: model.StatusActive,
Repository: model.Repository{
URL: repoURL,
Source: repoSource,
},
VersionDetail: model.VersionDetail{
Version: version,
},
Packages: []model.Package{pkg},
}
}

View file

@ -0,0 +1,105 @@
package commands
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
)
const (
DefaultRegistryURL = "https://registry.modelcontextprotocol.io"
TokenFileName = ".mcp_publisher_token" //nolint:gosec // Not a credential, just a filename
)
func LoginCommand(args []string) error {
if len(args) < 1 {
return errors.New("authentication method required\n\nUsage: mcp-publisher login <method>\n\nMethods:\n github Interactive GitHub authentication\n github-oidc GitHub Actions OIDC authentication\n dns DNS-based authentication (requires --domain and --private-key)\n http HTTP-based authentication (requires --domain and --private-key)\n none Anonymous authentication (for testing)")
}
method := args[0]
// Parse remaining flags based on method
loginFlags := flag.NewFlagSet("login", flag.ExitOnError)
var domain string
var privateKey string
var registryURL string
loginFlags.StringVar(&registryURL, "registry", DefaultRegistryURL, "Registry URL")
if method == "dns" || method == "http" {
loginFlags.StringVar(&domain, "domain", "", "Domain name")
loginFlags.StringVar(&privateKey, "private-key", "", "Private key (64-char hex)")
}
if err := loginFlags.Parse(args[1:]); err != nil {
return err
}
// Create auth provider based on method
var authProvider auth.Provider
switch method {
case "github":
authProvider = auth.NewGitHubATProvider(true, registryURL)
case "github-oidc":
authProvider = auth.NewGitHubOIDCProvider(registryURL)
case "dns":
if domain == "" || privateKey == "" {
return errors.New("dns authentication requires --domain and --private-key")
}
authProvider = auth.NewDNSProvider(registryURL, domain, privateKey)
case "http":
if domain == "" || privateKey == "" {
return errors.New("http authentication requires --domain and --private-key")
}
authProvider = auth.NewHTTPProvider(registryURL, domain, privateKey)
case "none":
authProvider = auth.NewNoneProvider(registryURL)
default:
return fmt.Errorf("unknown authentication method: %s\nFor a list of available methods, run: mcp-publisher login", method)
}
// Perform login
ctx := context.Background()
_, _ = fmt.Fprintf(os.Stdout, "Logging in with %s...\n", method)
if err := authProvider.Login(ctx); err != nil {
return fmt.Errorf("login failed: %w", err)
}
// Get and save token
token, err := authProvider.GetToken(ctx)
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
// Save token to file
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
tokenPath := filepath.Join(homeDir, TokenFileName)
tokenData := map[string]string{
"token": token,
"method": method,
"registry": registryURL,
}
jsonData, err := json.Marshal(tokenData)
if err != nil {
return fmt.Errorf("failed to marshal token data: %w", err)
}
if err := os.WriteFile(tokenPath, jsonData, 0600); err != nil {
return fmt.Errorf("failed to save token: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged in")
return nil
}

View file

@ -0,0 +1,43 @@
package commands
import (
"fmt"
"os"
"path/filepath"
)
func LogoutCommand() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
tokenPath := filepath.Join(homeDir, TokenFileName)
// Check if token file exists
if _, err := os.Stat(tokenPath); os.IsNotExist(err) {
_, _ = fmt.Fprintln(os.Stdout, "Not logged in")
return nil
}
// Remove token file
if err := os.Remove(tokenPath); err != nil {
return fmt.Errorf("failed to remove token: %w", err)
}
// Also clean up legacy token files if they exist
legacyFiles := []string{
".mcpregistry_github_token",
".mcpregistry_registry_token",
}
for _, file := range legacyFiles {
path := filepath.Join(homeDir, file)
if _, err := os.Stat(path); err == nil {
os.Remove(path) // Ignore errors for legacy files
}
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged out")
return nil
}

View file

@ -0,0 +1,132 @@
package commands
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
func PublishCommand(args []string) error {
// Check for server.json file
serverFile := "server.json"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
serverFile = args[0]
}
// Read server.json
serverData, err := os.ReadFile(serverFile)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("server.json not found. Run 'mcp-publisher init' to create one")
}
return fmt.Errorf("failed to read server.json: %w", err)
}
// Validate JSON
var serverJSON map[string]any
if err := json.Unmarshal(serverData, &serverJSON); err != nil {
return fmt.Errorf("invalid server.json: %w", err)
}
// 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
}
// Publish to registry
_, _ = fmt.Fprintf(os.Stdout, "Publishing to %s...\n", registryURL)
if err := publishToRegistry(registryURL, serverData, token); err != nil {
return fmt.Errorf("publish failed: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully published")
return nil
}
func publishToRegistry(registryURL string, serverData []byte, token string) error {
// Parse the server JSON data
var serverDetails map[string]any
err := json.Unmarshal(serverData, &serverDetails)
if err != nil {
return fmt.Errorf("error parsing server.json file: %w", err)
}
// Create the publish request payload
var publishReq map[string]any
if _, hasServerField := serverDetails["server"]; hasServerField {
// Already in PublishRequest format
publishReq = serverDetails
} else {
// Wrap in server field
publishReq = map[string]any{
"server": serverDetails,
}
}
// Convert to JSON
jsonData, err := json.Marshal(publishReq)
if err != nil {
return fmt.Errorf("error serializing request: %w", err)
}
// Ensure URL ends with the publish endpoint
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
publishURL := registryURL + "v0/publish"
// Create and send request
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, publishURL, 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()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
return nil
}