diff --git a/.gitignore b/.gitignore index 7eb2a6be..8ea80872 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/* .mcpregistry* **/bin cmd/registry/registry +publisher diff --git a/tools/publisher/README.md b/tools/publisher/README.md index df144927..114611ca 100644 --- a/tools/publisher/README.md +++ b/tools/publisher/README.md @@ -18,12 +18,23 @@ The compiled binary will be placed in the `bin` directory. ## Usage +The tool supports two main commands: + +### Publishing a server + ```bash # Basic usage -./bin/mcp-publisher -registry-url -mcp-file +./bin/mcp-publisher publish -registry-url -mcp-file # Force a new login even if a token exists -./bin/mcp-publisher -registry-url -mcp-file -login +./bin/mcp-publisher publish -registry-url -mcp-file -login +``` + +### Creating a server.json file + +```bash +# Create a new server.json file +./bin/mcp-publisher create --name "io.github.owner/repo" --description "My server" --repo-url "https://github.com/owner/repo" ``` ### Command-line Arguments @@ -33,6 +44,87 @@ The compiled binary will be placed in the `bin` directory. - `-login`: Force a new GitHub authentication even if a token already exists (overwrites existing token file) - `-auth-method`: Authentication method to use (default: github-oauth) +## Creating a server.json file + +The tool provides a `create` command to help generate a properly formatted `server.json` file. This command takes various flags to specify the server details and generates a complete server.json file that you can then modify as needed. + +### Usage + +```bash +./bin/mcp-publisher create [flags] +``` + +### Create Command Flags + +#### Required Flags +- `--name`, `-n`: Server name (e.g., io.github.owner/repo-name) +- `--description`, `-d`: Server description +- `--repo-url`: Repository URL + +#### Optional Flags +- `--version`, `-v`: Server version (default: "1.0.0") +- `--repo-source`: Repository source (default: "github") +- `--output`, `-o`: Output file path (default: "server.json") +- `--execute`, `-e`: Command to execute the server (generates runtime arguments) +- `--registry`: Package registry name (default: "npm") +- `--package-name`: Package name (defaults to server name) +- `--package-version`: Package version (defaults to server version) +- `--runtime-hint`: Runtime hint (e.g., "docker") +- `--env-var`: Environment variable in format NAME:DESCRIPTION (can be repeated) +- `--package-arg`: Package argument in format VALUE:DESCRIPTION (can be repeated) + +### Create Examples + +#### Basic NPX Server + +```bash +./bin/mcp-publisher create \ + --name "io.github.example/my-server" \ + --description "My MCP server" \ + --repo-url "https://github.com/example/my-server" \ + --execute "npx @example/my-server --verbose" \ + --env-var "API_KEY:Your API key for the service" +``` + +#### Docker Server + +```bash +./bin/mcp-publisher create \ + --name "io.github.example/docker-server" \ + --description "Docker-based MCP server" \ + --repo-url "https://github.com/example/docker-server" \ + --runtime-hint "docker" \ + --execute "docker run --mount type=bind,src=/data,dst=/app/data example/server" \ + --env-var "CONFIG_PATH:Path to configuration file" +``` + +#### Server with Package Arguments + +```bash +./bin/mcp-publisher create \ + --name "io.github.example/full-server" \ + --description "Complete server example" \ + --repo-url "https://github.com/example/full-server" \ + --execute "npx @example/server" \ + --package-arg "-s:Specify services and permissions" \ + --package-arg "--config:Configuration file path" \ + --env-var "API_KEY:Service API key" \ + --env-var "DEBUG:Enable debug mode" +``` + +The `create` command will generate a `server.json` file with: +- Proper structure and formatting +- Runtime arguments parsed from the `--execute` command +- Environment variables with descriptions +- Package arguments for user configuration +- All necessary metadata + +After creation, you may need to manually edit the file to: +- Adjust argument descriptions and requirements +- Set environment variable optionality (`is_required`, `is_secret`) +- Add remote server configurations +- Fine-tune runtime and package arguments + ## Authentication The tool has been simplified to use **GitHub OAuth device flow authentication exclusively**. Previous versions supported multiple authentication methods, but this version focuses solely on GitHub OAuth for better security and user experience. @@ -45,7 +137,9 @@ The tool has been simplified to use **GitHub OAuth device flow authentication ex **Note**: Authentication is performed via GitHub OAuth App, which you must authorize for the respective resources (e.g., organization access if publishing organization repositories). -## Example +## Publishing Example + +To publish an existing server.json file to the registry: 1. Prepare your `server.json` file with your server details: @@ -90,7 +184,7 @@ The tool has been simplified to use **GitHub OAuth device flow authentication ex 2. Run the publisher tool: ```bash -./bin/mcp-publisher --registry-url "https://mcp-registry.example.com" --mcp-file "./server.json" +./bin/mcp-publisher publish --registry-url "https://mcp-registry.example.com" --mcp-file "./server.json" ``` 3. Follow the authentication instructions in the terminal if prompted. diff --git a/tools/publisher/main.go b/tools/publisher/main.go index 66fd19bd..1ad26299 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -16,22 +16,109 @@ import ( "github.com/modelcontextprotocol/registry/tools/publisher/auth/github" ) +// Server structure types for JSON generation +type Repository struct { + URL string `json:"url"` + Source string `json:"source"` +} + +type VersionDetail struct { + Version string `json:"version"` +} + +type EnvironmentVariable struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type RuntimeArgument struct { + Description string `json:"description"` + IsRequired bool `json:"is_required"` + Format string `json:"format"` + Value string `json:"value"` + Default string `json:"default"` + Type string `json:"type"` + ValueHint string `json:"value_hint"` +} + +type Package struct { + RegistryName string `json:"registry_name"` + Name string `json:"name"` + Version string `json:"version"` + RuntimeHint string `json:"runtime_hint,omitempty"` + RuntimeArguments []RuntimeArgument `json:"runtime_arguments,omitempty"` + PackageArguments []RuntimeArgument `json:"package_arguments,omitempty"` + EnvironmentVariables []EnvironmentVariable `json:"environment_variables,omitempty"` +} + +type ServerJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Repository Repository `json:"repository"` + VersionDetail VersionDetail `json:"version_detail"` + Packages []Package `json:"packages"` +} + func main() { + if len(os.Args) < 2 { + printUsage() + return + } + + command := os.Args[1] + switch command { + case "publish": + publishCommand() + case "create": + createCommand() + default: + printUsage() + } +} + +func printUsage() { + fmt.Fprint(os.Stdout, "MCP Registry Publisher Tool\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Usage:\n") + fmt.Fprint(os.Stdout, " mcp-publisher publish [flags] Publish a server.json file to the registry\n") + fmt.Fprint(os.Stdout, " mcp-publisher create [flags] Create a new server.json file\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Use 'mcp-publisher --help' for more information about a command.\n") +} + +func publishCommand() { + publishFlags := flag.NewFlagSet("publish", flag.ExitOnError) + var registryURL string var mcpFilePath string var forceLogin bool var authMethod string // Command-line flags for configuration - flag.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") - flag.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)") - flag.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists") - flag.StringVar(&authMethod, "auth-method", "github-oauth", "authentication method to use (default: github-oauth)") + publishFlags.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") + publishFlags.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)") + publishFlags.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists") + publishFlags.StringVar(&authMethod, "auth-method", "github-oauth", "authentication method to use (default: github-oauth)") - flag.Parse() + // Set custom usage function + publishFlags.Usage = func() { + fmt.Fprint(os.Stdout, "Usage: mcp-publisher publish [flags]\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Publish a server.json file to the registry\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Flags:\n") + fmt.Fprint(os.Stdout, " --registry-url string URL of the registry (required)\n") + fmt.Fprint(os.Stdout, " --mcp-file string path to the MCP file (required)\n") + fmt.Fprint(os.Stdout, " --login force a new login even if a token exists\n") + fmt.Fprint(os.Stdout, " --auth-method string authentication method to use (default: github-oauth)\n") + } + + if err := publishFlags.Parse(os.Args[2:]); err != nil { + log.Fatalf("Error parsing flags: %v", err) + } if registryURL == "" || mcpFilePath == "" { - flag.Usage() + publishFlags.Usage() return } @@ -79,6 +166,127 @@ func main() { log.Println("Successfully published to registry!") } +func createCommand() { + createFlags := flag.NewFlagSet("create", flag.ExitOnError) + + // Basic server information flags + var name string + var description string + var version string + var repoURL string + var repoSource string + var output string + + // Package information flags + var registryName string + var packageName string + var packageVersion string + var runtimeHint string + var execute string + + // Repeatable flags + var envVars []string + var packageArgs []string + + createFlags.StringVar(&name, "name", "", "Server name (e.g., io.github.owner/repo-name) (required)") + createFlags.StringVar(&name, "n", "", "Server name (shorthand)") + createFlags.StringVar(&description, "description", "", "Server description (required)") + createFlags.StringVar(&description, "d", "", "Server description (shorthand)") + createFlags.StringVar(&version, "version", "1.0.0", "Server version") + createFlags.StringVar(&version, "v", "1.0.0", "Server version (shorthand)") + createFlags.StringVar(&repoURL, "repo-url", "", "Repository URL (required)") + createFlags.StringVar(&repoSource, "repo-source", "github", "Repository source") + createFlags.StringVar(&output, "output", "server.json", "Output file path") + createFlags.StringVar(&output, "o", "server.json", "Output file path (shorthand)") + + createFlags.StringVar(®istryName, "registry", "npm", "Package registry name") + createFlags.StringVar(&packageName, "package-name", "", "Package name (defaults to server name)") + createFlags.StringVar(&packageVersion, "package-version", "", "Package version (defaults to server version)") + createFlags.StringVar(&runtimeHint, "runtime-hint", "", "Runtime hint (e.g., docker)") + createFlags.StringVar(&execute, "execute", "", "Command to execute the server") + createFlags.StringVar(&execute, "e", "", "Command to execute the server (shorthand)") + + // Custom flag for environment variables + createFlags.Func("env-var", "Environment variable in format NAME:DESCRIPTION (can be repeated)", func(value string) error { + envVars = append(envVars, value) + return nil + }) + + // Custom flag for package arguments + createFlags.Func("package-arg", "Package argument in format VALUE:DESCRIPTION (can be repeated)", func(value string) error { + packageArgs = append(packageArgs, value) + return nil + }) + + // Set custom usage function + createFlags.Usage = func() { + fmt.Fprint(os.Stdout, "Usage: mcp-publisher create [flags]\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Create a new server.json file\n") + fmt.Fprint(os.Stdout, "\n") + fmt.Fprint(os.Stdout, "Flags:\n") + fmt.Fprint(os.Stdout, " --name/-n string Server name (e.g., io.github.owner/repo-name) (required)\n") + fmt.Fprint(os.Stdout, " --description/-d string Server description (required)\n") + fmt.Fprint(os.Stdout, " --repo-url string Repository URL (required)\n") + fmt.Fprint(os.Stdout, " --version/-v string Server version (default: 1.0.0)\n") + fmt.Fprint(os.Stdout, " --execute/-e string Command to execute the server\n") + fmt.Fprint(os.Stdout, " --output/-o string Output file path (default: server.json)\n") + fmt.Fprint(os.Stdout, " --registry string Package registry name (default: npm)\n") + fmt.Fprint(os.Stdout, " --package-name string Package name (defaults to server name)\n") + fmt.Fprint(os.Stdout, " --package-version string Package version (defaults to server version)\n") + fmt.Fprint(os.Stdout, " --runtime-hint string Runtime hint (e.g., docker)\n") + fmt.Fprint(os.Stdout, " --repo-source string Repository source (default: github)\n") + fmt.Fprint(os.Stdout, " --env-var string Environment variable in format NAME:DESCRIPTION (can be repeated)\n") + fmt.Fprint(os.Stdout, " --package-arg string Package argument in format VALUE:DESCRIPTION (can be repeated)\n") + } + + if err := createFlags.Parse(os.Args[2:]); err != nil { + log.Fatalf("Error parsing flags: %v", err) + } + + // Validate required flags + if name == "" { + log.Fatal("Error: --name/-n is required") + } + if description == "" { + log.Fatal("Error: --description/-d is required") + } + if repoURL == "" { + log.Fatal("Error: --repo-url is required") + } + + // Set defaults + if packageName == "" { + packageName = name + } + if packageVersion == "" { + packageVersion = version + } + + // Create server structure + server := createServerStructure(name, description, version, repoURL, repoSource, + registryName, packageName, packageVersion, runtimeHint, execute, envVars, packageArgs) + + // Convert to JSON + jsonData, err := json.MarshalIndent(server, "", " ") + if err != nil { + log.Fatalf("Error marshaling JSON: %v", err) + } + + // Write to file + err = os.WriteFile(output, jsonData, 0600) + if err != nil { + log.Fatalf("Error writing file: %v", err) + } + + log.Printf("Successfully created %s", output) + log.Println("You may need to edit the file to:") + log.Println(" - Add or modify package arguments") + log.Println(" - Set environment variable requirements") + log.Println(" - Add remote server configurations") + log.Println(" - Adjust runtime arguments") +} + // publishToRegistry sends the MCP server details to the registry with authentication func publishToRegistry(registryURL string, mcpData []byte, token string) error { // Parse the MCP JSON data @@ -131,3 +339,142 @@ func publishToRegistry(registryURL string, mcpData []byte, token string) error { log.Println(string(body)) return nil } + +func createServerStructure(name, description, version, repoURL, repoSource, registryName, + packageName, packageVersion, runtimeHint, execute string, envVars []string, packageArgs []string) ServerJSON { + // Parse environment variables + var environmentVariables []EnvironmentVariable + for _, envVar := range envVars { + parts := strings.SplitN(envVar, ":", 2) + if len(parts) == 2 { + environmentVariables = append(environmentVariables, EnvironmentVariable{ + Name: parts[0], + Description: parts[1], + }) + } else { + // If no description provided, use a default + environmentVariables = append(environmentVariables, EnvironmentVariable{ + Name: parts[0], + Description: fmt.Sprintf("Environment variable for %s", parts[0]), + }) + } + } + + // Parse package arguments + var packageArguments []RuntimeArgument + for i, pkgArg := range packageArgs { + parts := strings.SplitN(pkgArg, ":", 2) + value := parts[0] + description := fmt.Sprintf("Package argument %d", i+1) + if len(parts) == 2 { + description = parts[1] + } + + packageArguments = append(packageArguments, RuntimeArgument{ + Description: description, + IsRequired: true, // Package arguments are typically required + Format: "string", + Value: value, + Default: value, + Type: "positional", + ValueHint: value, + }) + } + + // Parse execute command to create runtime arguments + var runtimeArguments []RuntimeArgument + if execute != "" { + // Split the execute command into parts, handling quoted strings + parts := smartSplit(execute) + if len(parts) > 1 { + // Skip the first part (command) and add each argument as a runtime argument + for i, arg := range parts[1:] { + description := fmt.Sprintf("Runtime argument %d", i+1) + + // Try to provide better descriptions based on common patterns + switch { + case strings.HasPrefix(arg, "--"): + description = fmt.Sprintf("Command line flag: %s", arg) + case strings.HasPrefix(arg, "-") && len(arg) == 2: + description = fmt.Sprintf("Command line option: %s", arg) + case strings.Contains(arg, "="): + description = fmt.Sprintf("Configuration parameter: %s", arg) + case i > 0 && strings.HasPrefix(parts[i], "-"): + description = fmt.Sprintf("Value for %s", parts[i]) + } + + runtimeArguments = append(runtimeArguments, RuntimeArgument{ + Description: description, + IsRequired: false, + Format: "string", + Value: arg, + Default: arg, + Type: "positional", + ValueHint: arg, + }) + } + } + } + + // Create package + pkg := Package{ + RegistryName: registryName, + Name: packageName, + Version: packageVersion, + RuntimeHint: runtimeHint, + RuntimeArguments: runtimeArguments, + PackageArguments: packageArguments, + EnvironmentVariables: environmentVariables, + } + + // Create server structure + return ServerJSON{ + Name: name, + Description: description, + Repository: Repository{ + URL: repoURL, + Source: repoSource, + }, + VersionDetail: VersionDetail{ + Version: version, + }, + Packages: []Package{pkg}, + } +} + +// smartSplit splits a command string into parts, handling quoted strings and common shell patterns +func smartSplit(command string) []string { + var parts []string + var current strings.Builder + var inQuotes bool + var quoteChar rune + + for _, char := range command { + switch { + case char == '"' || char == '\'': + switch { + case !inQuotes: + inQuotes = true + quoteChar = char + case char == quoteChar: + inQuotes = false + quoteChar = 0 + default: + current.WriteRune(char) + } + case char == ' ' && !inQuotes: + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + default: + current.WriteRune(char) + } + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +}