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:
parent
edf3929504
commit
b8106340bc
4 changed files with 883 additions and 884 deletions
319
tools/publisher/commands/init.go
Normal file
319
tools/publisher/commands/init.go
Normal 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},
|
||||
}
|
||||
}
|
||||
105
tools/publisher/commands/login.go
Normal file
105
tools/publisher/commands/login.go
Normal 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(®istryURL, "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
|
||||
}
|
||||
43
tools/publisher/commands/logout.go
Normal file
43
tools/publisher/commands/logout.go
Normal 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
|
||||
}
|
||||
132
tools/publisher/commands/publish.go
Normal file
132
tools/publisher/commands/publish.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue