From 769d31fa5bd7c3a87dbd8f26a90d07b7df17c15b Mon Sep 17 00:00:00 2001 From: deerajkumar18 Date: Thu, 26 Feb 2026 17:51:03 +0000 Subject: [PATCH] Move the CLI to Cobra framework --- cmd/publisher/commands/init.go | 18 ++- cmd/publisher/commands/login.go | 196 ++++++++++++------------- cmd/publisher/commands/logout.go | 15 +- cmd/publisher/commands/publish.go | 17 ++- cmd/publisher/commands/publish_test.go | 12 +- cmd/publisher/commands/root.go | 33 +++++ cmd/publisher/commands/status.go | 82 +++++++---- cmd/publisher/commands/status_test.go | 160 ++++++++++---------- cmd/publisher/main.go | 148 +------------------ go.mod | 3 + go.sum | 10 ++ 11 files changed, 327 insertions(+), 367 deletions(-) create mode 100644 cmd/publisher/commands/root.go diff --git a/cmd/publisher/commands/init.go b/cmd/publisher/commands/init.go index 27cb664ac..4d8af35f7 100644 --- a/cmd/publisher/commands/init.go +++ b/cmd/publisher/commands/init.go @@ -13,9 +13,25 @@ import ( apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/spf13/cobra" ) -func InitCommand() error { +func init() { + mcpPublisherCmd.AddCommand(initCmd) +} + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Create a server.json file template", + Long: `This command creates a server.json file in the current directory with +auto-detected values from your project (package.json, git remote, etc.). + +After running init, edit the generated server.json to customize your +server's metadata before publishing.`, + RunE: runInitCmd, +} + +var runInitCmd = func(_ *cobra.Command, _ []string) error { // Check if server.json already exists if _, err := os.Stat("server.json"); err == nil { return errors.New("server.json already exists") diff --git a/cmd/publisher/commands/login.go b/cmd/publisher/commands/login.go index 091a70014..0e07ddbe3 100644 --- a/cmd/publisher/commands/login.go +++ b/cmd/publisher/commands/login.go @@ -4,15 +4,14 @@ import ( "context" "encoding/json" "errors" - "flag" "fmt" "os" "path/filepath" - "strings" "github.com/modelcontextprotocol/registry/cmd/publisher/auth" "github.com/modelcontextprotocol/registry/cmd/publisher/auth/azurekeyvault" "github.com/modelcontextprotocol/registry/cmd/publisher/auth/googlekms" + "github.com/spf13/cobra" ) const ( @@ -27,6 +26,8 @@ const ( type CryptoAlgorithm auth.CryptoAlgorithm +type Token string + type SignerType string type LoginFlags struct { @@ -36,10 +37,9 @@ type LoginFlags struct { KvVault string KvKeyName string KmsResource string - Token Token + Token string CryptoAlgorithm CryptoAlgorithm SignerType SignerType - ArgOffset int } const ( @@ -62,54 +62,81 @@ func (c *CryptoAlgorithm) Set(v string) error { return fmt.Errorf("invalid algorithm: %q (allowed: ed25519, ecdsap384)", v) } -type Token string - -func parseLoginFlags(method string, args []string) (LoginFlags, error) { - var flags LoginFlags - loginFlags := flag.NewFlagSet("login", flag.ExitOnError) - flags.CryptoAlgorithm = CryptoAlgorithm(auth.AlgorithmEd25519) - flags.SignerType = NoSignerType - flags.ArgOffset = 1 - loginFlags.StringVar(&flags.RegistryURL, "registry", DefaultRegistryURL, "Registry URL") +func (c *CryptoAlgorithm) Type() string { + return "cryptoAlgorithm" +} - // Add --token flag for GitHub authentication - var token string - if method == MethodGitHub { - loginFlags.StringVar(&token, "token", "", "GitHub Personal Access Token") - } +var flags LoginFlags + +func init() { + mcpPublisherCmd.AddCommand(loginCmd) + loginCmd.Flags().StringVar(&flags.RegistryURL, "registry", DefaultRegistryURL, "Registry URL") + loginCmd.Flags().StringVarP(&flags.Token, "token", "t", "", "GitHub Personal Access Token") + loginCmd.Flags().StringVarP(&flags.Domain, "domain", "d", "", "Domain name") + loginCmd.Flags().StringVarP(&flags.KvVault, "vault", "v", "", "The name of the Azure Key Vault resource") + loginCmd.Flags().StringVarP(&flags.KvKeyName, "key", "k", "", "Name of the signing key in the Azure Key Vault") + loginCmd.Flags().StringVarP(&flags.KmsResource, "resource", "r", "", "Google Cloud KMS resource name (e.g. projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1)") + loginCmd.Flags().StringVarP(&flags.PrivateKey, "private-key", "p", "", "Private key (hex)") + loginCmd.Flags().VarP(&flags.CryptoAlgorithm, "algorithm", "a", "Cryptographic algorithm (ed25519, ecdsap384)") +} - if method == "dns" || method == "http" { - loginFlags.StringVar(&flags.Domain, "domain", "", "Domain name") - if len(args) > 1 { - switch args[1] { - case string(AzureKeyVaultSignerType): - flags.SignerType = AzureKeyVaultSignerType - loginFlags.StringVar(&flags.KvVault, "vault", "", "The name of the Azure Key Vault resource") - loginFlags.StringVar(&flags.KvKeyName, "key", "", "Name of the signing key in the Azure Key Vault") - flags.ArgOffset = 2 - case string(GoogleKMSSignerType): - flags.SignerType = GoogleKMSSignerType - loginFlags.StringVar(&flags.KmsResource, "resource", "", "Google Cloud KMS resource name (e.g. projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1)") - flags.ArgOffset = 2 - } +var loginCmd = &cobra.Command{ + Use: "login [options]", + Short: "Authenticate with the registry", + Long: `Methods: + github Interactive GitHub authentication + github-oidc GitHub Actions OIDC authentication + dns DNS-based authentication (requires --domain) + http HTTP-based authentication (requires --domain) + none Anonymous authentication (for testing)`, + Args: func(_ *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New(`authentication method required + + Usage: mcp-publisher login [] + + Methods: + github Interactive GitHub authentication + github-oidc GitHub Actions OIDC authentication + dns DNS-based authentication (requires --domain) + http HTTP-based authentication (requires --domain) + none Anonymous authentication (for testing) + + Signing providers: + azure-key-vault Sign using Azure Key Vault + google-kms Sign using Google Cloud KMS + + The dns and http methods require a --private-key for in-process signing. For + out-of-process signing, use one of the supported signing providers. Signing is + needed for an authentication challenge with the registry. + + The github and github-oidc methods do not support signing providers and + authenticate using the GitHub as an identity provider. + + Examples: + # Interactive GitHub login, using device code flow + mcp-publisher login github + + # Sign in using a specific Ed25519 private key for DNS authentication + mcp-publisher login dns -algorithm ed25519 -domain example.com -private-key <64 hex chars> + + # Sign in using a specific ECDSA P-384 private key for DNS authentication + mcp-publisher login dns -algorithm ecdsap384 -domain example.com -private-key <96 hex chars> + + # Sign in with gcloud CLI, use Google Cloud KMS for signing in DNS authentication + gcloud auth application-default login + mcp-publisher login dns google-kms -domain example.com -resource projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1 + + # Sign in with az CLI, use Azure Key Vault for signing in HTTP authentication + az login + mcp-publisher login http azure-key-vault -domain example.com -vault myvault -key mysigningkey`) } - if flags.SignerType == NoSignerType { - flags.SignerType = InProcessSignerType - loginFlags.StringVar(&flags.PrivateKey, "private-key", "", "Private key (hex)") - loginFlags.Var(&flags.CryptoAlgorithm, "algorithm", "Cryptographic algorithm (ed25519, ecdsap384)") - } - } - err := loginFlags.Parse(args[flags.ArgOffset:]) - if err == nil { - flags.RegistryURL = strings.TrimRight(flags.RegistryURL, "/") - } - - // Store the token in flags if it was provided - if method == MethodGitHub { - flags.Token = Token(token) - } - - return flags, err + return nil + }, + Example: ` + mcp-publisher login github + mcp-publisher login dns --domain example.com --private-key `, + RunE: runLoginCommand, } func createSigner(flags LoginFlags) (auth.Signer, error) { @@ -127,10 +154,10 @@ func createSigner(flags LoginFlags) (auth.Signer, error) { } } -func createAuthProvider(method, registryURL, domain string, token Token, signer auth.Signer) (auth.Provider, error) { +func createAuthProvider(method, registryURL, domain string, token string, signer auth.Signer) (auth.Provider, error) { switch method { case MethodGitHub: - return auth.NewGitHubATProvider(true, registryURL, string(token)), nil + return auth.NewGitHubATProvider(true, registryURL, token), nil case MethodGitHubOIDC: return auth.NewGitHubOIDCProvider(registryURL), nil case MethodDNS: @@ -150,59 +177,26 @@ func createAuthProvider(method, registryURL, domain string, token Token, signer } } -func LoginCommand(args []string) error { - if len(args) < 1 { - return errors.New(`authentication method required - -Usage: mcp-publisher login [] - -Methods: - github Interactive GitHub authentication - github-oidc GitHub Actions OIDC authentication - dns DNS-based authentication (requires --domain) - http HTTP-based authentication (requires --domain) - none Anonymous authentication (for testing) - -Signing providers: - azure-key-vault Sign using Azure Key Vault - google-kms Sign using Google Cloud KMS - -The dns and http methods require a --private-key for in-process signing. For -out-of-process signing, use one of the supported signing providers. Signing is -needed for an authentication challenge with the registry. - -The github and github-oidc methods do not support signing providers and -authenticate using the GitHub as an identity provider. - -Examples: - - # Interactive GitHub login, using device code flow - mcp-publisher login github - - # Sign in using a specific Ed25519 private key for DNS authentication - mcp-publisher login dns -algorithm ed25519 -domain example.com -private-key <64 hex chars> - - # Sign in using a specific ECDSA P-384 private key for DNS authentication - mcp-publisher login dns -algorithm ecdsap384 -domain example.com -private-key <96 hex chars> - - # Sign in with gcloud CLI, use Google Cloud KMS for signing in DNS authentication - gcloud auth application-default login - mcp-publisher login dns google-kms -domain example.com -resource projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1 - - # Sign in with az CLI, use Azure Key Vault for signing in HTTP authentication - az login - mcp-publisher login http azure-key-vault -domain example.com -vault myvault -key mysigningkey - - `) - } - +var runLoginCommand = func(_ *cobra.Command, args []string) error { + var ( + signer auth.Signer + err error + ) method := args[0] - flags, err := parseLoginFlags(method, args) - if err != nil { - return err + flags.SignerType = NoSignerType + if method == "http" || method == "dns" { + if len(args) > 1 { + switch args[1] { + case string(AzureKeyVaultSignerType): + flags.SignerType = AzureKeyVaultSignerType + case string(GoogleKMSSignerType): + flags.SignerType = GoogleKMSSignerType + } + } else { + flags.SignerType = InProcessSignerType + } } - var signer auth.Signer if flags.SignerType != NoSignerType { signer, err = createSigner(flags) if err != nil { diff --git a/cmd/publisher/commands/logout.go b/cmd/publisher/commands/logout.go index 3ac9abda7..0095dc8c9 100644 --- a/cmd/publisher/commands/logout.go +++ b/cmd/publisher/commands/logout.go @@ -4,9 +4,22 @@ import ( "fmt" "os" "path/filepath" + + "github.com/spf13/cobra" ) -func LogoutCommand() error { +func init() { + mcpPublisherCmd.AddCommand(logoutCmd) +} + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Clear saved authentication", + Long: `This command removes the saved authentication token from your system.`, + RunE: LogoutCommand, +} + +var LogoutCommand = func(_ *cobra.Command, _ []string) error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get home directory: %w", err) diff --git a/cmd/publisher/commands/publish.go b/cmd/publisher/commands/publish.go index da7de7da8..ca4ca8b9a 100644 --- a/cmd/publisher/commands/publish.go +++ b/cmd/publisher/commands/publish.go @@ -13,9 +13,24 @@ import ( "strings" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/spf13/cobra" ) -func PublishCommand(args []string) error { +func init() { + mcpPublisherCmd.AddCommand(publishCmd) +} + +var publishCmd = &cobra.Command{ + Use: "publish [server.json]", + Short: "Publish server.json to the registry", + Long: `Arguments: + server.json Path to the server.json file (default: ./server.json) + + You must be logged in before publishing. Run 'mcp-publisher login' first.`, + RunE: RunPublishCommand, +} + +var RunPublishCommand = func(_ *cobra.Command, args []string) error { // Check for server.json file serverFile := "server.json" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { diff --git a/cmd/publisher/commands/publish_test.go b/cmd/publisher/commands/publish_test.go index 27fcdfa11..90bd02339 100644 --- a/cmd/publisher/commands/publish_test.go +++ b/cmd/publisher/commands/publish_test.go @@ -31,7 +31,7 @@ func TestPublishCommand_Success(t *testing.T) { CreateTestServerJSON(t, serverJSON) // Run publish command - err := commands.PublishCommand([]string{}) + err := commands.RunPublishCommand(nil, []string{}) // Should succeed assert.NoError(t, err) @@ -89,7 +89,7 @@ func TestPublishCommand_422ValidationFlow(t *testing.T) { CreateTestServerJSON(t, serverJSON) // Run publish command - err := commands.PublishCommand([]string{}) + err := commands.RunPublishCommand(nil, []string{}) // Should fail with validation error require.Error(t, err) @@ -149,7 +149,7 @@ func TestPublishCommand_422WithMultipleIssues(t *testing.T) { } CreateTestServerJSON(t, serverJSON) - err := commands.PublishCommand([]string{}) + err := commands.RunPublishCommand(nil, []string{}) require.Error(t, err) assert.Equal(t, 1, validateCallCount, "validate endpoint should be called") @@ -165,7 +165,7 @@ func TestPublishCommand_NoToken(t *testing.T) { } CreateTestServerJSON(t, serverJSON) - err := commands.PublishCommand([]string{}) + err := commands.RunPublishCommand(nil, []string{}) require.Error(t, err) assert.Contains(t, err.Error(), "not authenticated") @@ -193,7 +193,7 @@ func TestPublishCommand_Non422Error(t *testing.T) { } CreateTestServerJSON(t, serverJSON) - err := commands.PublishCommand([]string{}) + err := commands.RunPublishCommand(nil, []string{}) require.Error(t, err) assert.Contains(t, err.Error(), "publish failed") @@ -337,7 +337,7 @@ func TestPublishCommand_DeprecatedSchema(t *testing.T) { } CreateTestServerJSON(t, serverJSON) - err := commands.PublishCommand([]string{}) + err := commands.RunPublishCommand(nil, []string{}) if tt.expectError { require.Error(t, err, "Expected error for test case: %s", tt.name) diff --git a/cmd/publisher/commands/root.go b/cmd/publisher/commands/root.go new file mode 100644 index 000000000..c8b9b84d9 --- /dev/null +++ b/cmd/publisher/commands/root.go @@ -0,0 +1,33 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + Version string + GitCommit string + BuildTime string +) + +var mcpPublisherCmd = &cobra.Command{ + Use: "mcp-publisher [arguments]", + Short: "MCP Registry Publisher Tool", +} + +func SetVersionInfo(version, gitCommit, buildTime string) { + Version = version + GitCommit = gitCommit + BuildTime = buildTime + + mcpPublisherCmd.Version = Version + mcpPublisherCmd.SetVersionTemplate( + fmt.Sprintf("mcp-publisher %s (commit: %s, built: %s)", Version, GitCommit, BuildTime), + ) +} + +func ExecuteMcpPublisherCmd() error { + return mcpPublisherCmd.Execute() +} diff --git a/cmd/publisher/commands/status.go b/cmd/publisher/commands/status.go index bcfacf5f1..aa4320a3f 100644 --- a/cmd/publisher/commands/status.go +++ b/cmd/publisher/commands/status.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "errors" - "flag" "fmt" "io" "net/http" @@ -14,6 +13,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/spf13/cobra" ) // StatusUpdateRequest represents the request body for status update endpoints @@ -53,45 +54,70 @@ 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)") +type StatusFlags struct { + Status string + Message string + AllVersions bool + SkipConfirm bool +} - if err := fs.Parse(args); err != nil { - return err - } +var StatusFlg StatusFlags +func init() { + mcpPublisherCmd.AddCommand(statusCmd) + statusCmd.Flags().StringVarP(&StatusFlg.Status, "status", "s", "", "New status: active, deprecated, or deleted (required)") + statusCmd.Flags().StringVarP(&StatusFlg.Message, "message", "m", "", "Optional status message explaining the change") + statusCmd.Flags().BoolVar(&StatusFlg.AllVersions, "all-versions", false, "Apply status change to all versions of the server") + statusCmd.Flags().BoolVarP(&StatusFlg.SkipConfirm, "yes", "y", false, "Skip confirmation prompt for bulk operations") +} + +var statusCmd = &cobra.Command{ + Use: "status --status [flags] [version]", + Short: "Update the status of a server version", + Args: func(_ *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("server name is required") + } + return nil + }, + Long: `Arguments: + server-name Full server name (e.g., io.github.user/my-server) + version Server version to update (required unless --all-versions is set)`, + Example: ` # Deprecate a specific version + mcp-publisher status --status deprecated --message "Please upgrade to 2.0.0" + io.github.user/my-server 1.0.0 + # Delete a version with security issues + mcp-publisher status --status deleted --message "Critical security vulnerability" + io.github.user/my-server 1.0.0 + # Restore a version to active + mcp-publisher status --status active io.github.user/my-server 1.0.0 + # Deprecate all versions + mcp-publisher status --status deprecated --all-versions --message "Project archived" + io.github.user/my-server`, + RunE: RunStatusCommand, +} + +var RunStatusCommand = func(_ *cobra.Command, args []string) error { // Validate required arguments - if *status == "" { + if StatusFlg.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 [flags] [version]") + if !validStatuses[StatusFlg.Status] { + return fmt.Errorf("invalid status '%s'. Must be one of: active, deprecated, deleted", StatusFlg.Status) } - serverName := remainingArgs[0] + serverName := args[0] var version string // Get version if provided (required unless --all-versions is set) - if !*allVersions { - if len(remainingArgs) < 2 { + if !StatusFlg.AllVersions { + if len(args) < 2 { return errors.New("version is required unless --all-versions flag is set\n\nUsage: mcp-publisher status --status [flags] ") } - version = remainingArgs[1] + version = args[1] } // Load saved token @@ -121,10 +147,10 @@ func StatusCommand(args []string) error { } // Update status - if *allVersions { - return updateAllVersionsStatus(registryURL, serverName, *status, *message, token, *yes) + if StatusFlg.AllVersions { + return updateAllVersionsStatus(registryURL, serverName, StatusFlg.Status, StatusFlg.Message, token, StatusFlg.SkipConfirm) } - return updateVersionStatus(registryURL, serverName, version, *status, *message, token) + return updateVersionStatus(registryURL, serverName, version, StatusFlg.Status, StatusFlg.Message, token) } func updateVersionStatus(registryURL, serverName, version, status, statusMessage, token string) error { diff --git a/cmd/publisher/commands/status_test.go b/cmd/publisher/commands/status_test.go index fa1f2d8cc..89f9cce40 100644 --- a/cmd/publisher/commands/status_test.go +++ b/cmd/publisher/commands/status_test.go @@ -11,6 +11,8 @@ func TestStatusCommand_Validation(t *testing.T) { tests := []struct { name string args []string + status string + allVersions bool expectError bool errorSubstr string }{ @@ -22,37 +24,38 @@ func TestStatusCommand_Validation(t *testing.T) { }, { name: "invalid status value", - args: []string{"--status", "invalid", "io.github.user/my-server", "1.0.0"}, + status: "invalid", + args: []string{"io.github.user/my-server", "1.0.0"}, expectError: true, errorSubstr: "invalid status 'invalid'", }, - { - name: "missing server name", - args: []string{"--status", "deprecated"}, - expectError: true, - errorSubstr: "server name is required", - }, { name: "missing version without --all-versions", - args: []string{"--status", "deprecated", "io.github.user/my-server"}, + status: "deprecated", + args: []string{"io.github.user/my-server"}, expectError: true, errorSubstr: "version is required unless --all-versions", }, { name: "valid args passes validation", - args: []string{"--status", "deprecated", "io.github.user/my-server", "1.0.0"}, + status: "deprecated", + args: []string{"io.github.user/my-server", "1.0.0"}, expectError: false, }, { name: "valid args with --all-versions passes validation", - args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server"}, + status: "deprecated", + allVersions: true, + args: []string{"io.github.user/my-server"}, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := commands.StatusCommand(tt.args) + commands.StatusFlg.Status = tt.status + commands.StatusFlg.AllVersions = tt.allVersions + err := commands.RunStatusCommand(nil, tt.args) if tt.expectError { if err == nil { @@ -77,46 +80,6 @@ func TestStatusCommand_Validation(t *testing.T) { } } -func TestStatusCommand_ServerNameValidation(t *testing.T) { - tests := []struct { - name string - serverName string - }{ - { - name: "valid github server name", - serverName: "io.github.user/my-server", - }, - { - name: "valid domain server name", - serverName: "com.example/my-server", - }, - { - name: "server name with dashes", - serverName: "io.github.user/my-cool-server", - }, - { - name: "server name with underscores", - serverName: "io.github.user/my_server", - }, - { - name: "server name with dots", - serverName: "io.github.user/my.server", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args := []string{"--status", "deprecated", tt.serverName, "1.0.0"} - err := commands.StatusCommand(args) - - // Should pass validation (server name format is not validated by CLI) - if err != nil && strings.Contains(err.Error(), "server name is required") { - t.Errorf("Server name '%s' was rejected", tt.serverName) - } - }) - } -} - func TestStatusCommand_VersionValidation(t *testing.T) { tests := []struct { name string @@ -146,8 +109,9 @@ func TestStatusCommand_VersionValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - args := []string{"--status", "deprecated", "io.github.user/my-server", tt.version} - err := commands.StatusCommand(args) + commands.StatusFlg.Status = "deprecated" + args := []string{"io.github.user/my-server", tt.version} + err := commands.RunStatusCommand(nil, args) // Should pass validation (version format is not validated by CLI) if err != nil && strings.Contains(err.Error(), "version is required") { @@ -160,31 +124,33 @@ func TestStatusCommand_VersionValidation(t *testing.T) { func TestStatusCommand_AllVersionsFlag(t *testing.T) { tests := []struct { name string + status string + allVersions bool args []string expectError bool errorSubstr string }{ { name: "all-versions without version arg passes validation", - args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server"}, + status: "deprecated", + allVersions: true, + args: []string{"io.github.user/my-server"}, expectError: false, }, { name: "all-versions with extra version arg still works", - args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server", "1.0.0"}, + status: "deprecated", + allVersions: true, + args: []string{"io.github.user/my-server", "1.0.0"}, expectError: false, }, - { - name: "missing server name with all-versions", - args: []string{"--status", "deprecated", "--all-versions"}, - expectError: true, - errorSubstr: "server name is required", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := commands.StatusCommand(tt.args) + commands.StatusFlg.Status = tt.status + commands.StatusFlg.AllVersions = tt.allVersions + err := commands.RunStatusCommand(nil, tt.args) if tt.expectError { if err == nil { @@ -210,22 +176,33 @@ func TestStatusCommand_AllVersionsFlag(t *testing.T) { func TestStatusCommand_FlagCombinations(t *testing.T) { tests := []struct { - name string - args []string + name string + status string + allVersions bool + message string + args []string }{ { - name: "status with message", - args: []string{"--status", "deprecated", "--message", "Please upgrade to v2", "io.github.user/my-server", "1.0.0"}, + name: "status with message", + status: "deprecated", + message: "Please upgrade to v2", + args: []string{"io.github.user/my-server", "1.0.0"}, }, { - name: "all-versions with message", - args: []string{"--status", "deprecated", "--all-versions", "--message", "All versions deprecated", "io.github.user/my-server"}, + name: "all-versions with message", + status: "deprecated", + message: "All versions deprecated", + allVersions: true, + args: []string{"io.github.user/my-server"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := commands.StatusCommand(tt.args) + commands.StatusFlg.Status = tt.status + commands.StatusFlg.AllVersions = tt.allVersions + commands.StatusFlg.Message = tt.message + err := commands.RunStatusCommand(nil, tt.args) // All these should pass CLI validation // They may fail at auth or API level which is acceptable if err != nil { @@ -244,22 +221,25 @@ func TestStatusCommand_FlagCombinations(t *testing.T) { func TestStatusCommand_MissingStatus(t *testing.T) { // Test various ways status flag can be missing tests := []struct { - name string - args []string + name string + status string + args []string }{ { name: "no status flag at all", args: []string{"io.github.user/my-server", "1.0.0"}, }, { - name: "empty status value", - args: []string{"--status", "", "io.github.user/my-server", "1.0.0"}, + name: "empty status value", + status: "", + args: []string{"io.github.user/my-server", "1.0.0"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := commands.StatusCommand(tt.args) + commands.StatusFlg.Status = tt.status + err := commands.RunStatusCommand(nil, tt.args) if err == nil { t.Errorf("Expected error for missing status but got none") @@ -274,26 +254,40 @@ func TestStatusCommand_MissingStatus(t *testing.T) { func TestStatusCommand_YesFlag(t *testing.T) { tests := []struct { - name string - args []string + name string + status string + skipConfirm bool + allVersions bool + args []string }{ { - name: "all-versions with --yes flag", - args: []string{"--status", "deprecated", "--all-versions", "--yes", "io.github.user/my-server"}, + name: "all-versions with --yes flag", + status: "deprecated", + skipConfirm: true, + allVersions: true, + args: []string{"io.github.user/my-server"}, }, { - name: "all-versions with -y shorthand", - args: []string{"--status", "deprecated", "--all-versions", "-y", "io.github.user/my-server"}, + name: "all-versions with -y shorthand", + status: "deprecated", + skipConfirm: true, + allVersions: true, + args: []string{"io.github.user/my-server"}, }, { - name: "yes flag with single version (flag accepted but not used)", - args: []string{"--status", "deprecated", "--yes", "io.github.user/my-server", "1.0.0"}, + name: "yes flag with single version (flag accepted but not used)", + status: "deprecated", + skipConfirm: true, + args: []string{"io.github.user/my-server", "1.0.0"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := commands.StatusCommand(tt.args) + commands.StatusFlg.Status = tt.status + commands.StatusFlg.AllVersions = tt.allVersions + commands.StatusFlg.SkipConfirm = tt.skipConfirm + err := commands.RunStatusCommand(nil, tt.args) // All these should pass CLI validation // They may fail at auth or API level which is acceptable if err != nil { diff --git a/cmd/publisher/main.go b/cmd/publisher/main.go index 20cea2bdf..451bcb852 100644 --- a/cmd/publisher/main.go +++ b/cmd/publisher/main.go @@ -1,8 +1,6 @@ package main import ( - "fmt" - "log" "os" "github.com/modelcontextprotocol/registry/cmd/publisher/commands" @@ -22,150 +20,8 @@ var ( ) func main() { - if len(os.Args) < 2 { - printUsage() + commands.SetVersionInfo(Version, GitCommit, BuildTime) + if err := commands.ExecuteMcpPublisherCmd(); err != nil { os.Exit(1) } - - // Check for help flag for subcommands - if len(os.Args) >= 3 && (os.Args[2] == "--help" || os.Args[2] == "-h") { - printCommandHelp(os.Args[1]) - return - } - - var err error - switch os.Args[1] { - case "init": - err = commands.InitCommand() - case "login": - err = commands.LoginCommand(os.Args[2:]) - case "logout": - err = commands.LogoutCommand() - case "publish": - err = commands.PublishCommand(os.Args[2:]) - case "status": - err = commands.StatusCommand(os.Args[2:]) - case "validate": - err = commands.ValidateCommand(os.Args[2:]) - case "--version", "-v", "version": - log.Printf("mcp-publisher %s (commit: %s, built: %s)", Version, GitCommit, BuildTime) - return - case "--help", "-h", "help": - printUsage() - default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1]) - printUsage() - os.Exit(1) - } - - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func printUsage() { - _, _ = fmt.Fprintln(os.Stdout, "MCP Registry Publisher Tool") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Usage:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher [arguments]") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Commands:") - _, _ = fmt.Fprintln(os.Stdout, " init Create a server.json file template") - _, _ = fmt.Fprintln(os.Stdout, " login Authenticate with the registry") - _, _ = fmt.Fprintln(os.Stdout, " logout Clear saved authentication") - _, _ = fmt.Fprintln(os.Stdout, " publish Publish server.json to the registry") - _, _ = fmt.Fprintln(os.Stdout, " status Update the status of a server version") - _, _ = fmt.Fprintln(os.Stdout, " validate Validate server.json without publishing") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Use 'mcp-publisher --help' for more information about a command.") -} - -func printCommandHelp(command string) { - switch command { - case "init": - _, _ = fmt.Fprintln(os.Stdout, "Create a server.json file template") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Usage:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher init") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "This command creates a server.json file in the current directory with") - _, _ = fmt.Fprintln(os.Stdout, "auto-detected values from your project (package.json, git remote, etc.).") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "After running init, edit the generated server.json to customize your") - _, _ = fmt.Fprintln(os.Stdout, "server's metadata before publishing.") - - case "login": - _, _ = fmt.Fprintln(os.Stdout, "Authenticate with the registry") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Usage:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login [options]") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Methods:") - _, _ = fmt.Fprintln(os.Stdout, " github Interactive GitHub authentication") - _, _ = fmt.Fprintln(os.Stdout, " github-oidc GitHub Actions OIDC authentication") - _, _ = fmt.Fprintln(os.Stdout, " dns DNS-based authentication (requires --domain)") - _, _ = fmt.Fprintln(os.Stdout, " http HTTP-based authentication (requires --domain)") - _, _ = fmt.Fprintln(os.Stdout, " none Anonymous authentication (for testing)") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Examples:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login dns --domain example.com --private-key ") - - case "logout": - _, _ = fmt.Fprintln(os.Stdout, "Clear saved authentication") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Usage:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher logout") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "This command removes the saved authentication token from your system.") - - case "publish": - _, _ = fmt.Fprintln(os.Stdout, "Publish server.json to the registry") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Usage:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish [server.json]") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Arguments:") - _, _ = fmt.Fprintln(os.Stdout, " server.json Path to the server.json file (default: ./server.json)") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "You must be logged in before publishing. Run 'mcp-publisher login' first.") - - case "status": - _, _ = fmt.Fprintln(os.Stdout, "Update the status of a server version") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Usage:") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status [flags] [version]") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Flags (must come before positional arguments):") - _, _ = fmt.Fprintln(os.Stdout, " --status string New status: active, deprecated, or deleted (required)") - _, _ = fmt.Fprintln(os.Stdout, " --message string Optional message explaining the status change") - _, _ = fmt.Fprintln(os.Stdout, " --all-versions Apply status change to all versions of the server") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Arguments:") - _, _ = fmt.Fprintln(os.Stdout, " server-name Full server name (e.g., io.github.user/my-server)") - _, _ = fmt.Fprintln(os.Stdout, " version Server version to update (required unless --all-versions is set)") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "Examples:") - _, _ = fmt.Fprintln(os.Stdout, " # Deprecate a specific version") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status deprecated --message \"Please upgrade to 2.0.0\" \\") - _, _ = fmt.Fprintln(os.Stdout, " io.github.user/my-server 1.0.0") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, " # Delete a version with security issues") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status deleted --message \"Critical security vulnerability\" \\") - _, _ = fmt.Fprintln(os.Stdout, " io.github.user/my-server 1.0.0") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, " # Restore a version to active") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status active io.github.user/my-server 1.0.0") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, " # Deprecate all versions") - _, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status deprecated --all-versions --message \"Project archived\" \\") - _, _ = fmt.Fprintln(os.Stdout, " io.github.user/my-server") - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintln(os.Stdout, "You must be logged in before updating status. Run 'mcp-publisher login' first.") - - default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) - printUsage() - } } diff --git a/go.mod b/go.mod index c4697b84b..3578cb455 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 go.opentelemetry.io/otel v1.40.0 @@ -52,6 +53,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -68,6 +70,7 @@ require ( github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/vbatts/tar-split v0.12.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect diff --git a/go.sum b/go.sum index d6fadaf69..fb7b3d9fe 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,7 @@ github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfN github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danielgtaylor/huma/v2 v2.35.0 h1:FRg3FgVKcMogVhbNY7FjyTwk+p/orLBR3hQBvXXg7dw= github.com/danielgtaylor/huma/v2 v2.35.0/go.mod h1:3elp5brzdyyZsPlDVvf6w8RLnklKp3abolr+5op3fP0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -81,6 +82,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -129,10 +132,16 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -164,6 +173,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=