registry/tools/publisher/main.go
adam jones e9fe6f9ab8
feat: Add creds for GitHub auth, add "none" auth to publisher (#276)
## Motivation and Context

- Adds credentials for the new MCP Registry Login GitHub Apps (local,
staging, prod)
- Adds support for "none" auth to publisher

## How Has This Been Tested?

- Tested publisher workflows locally
- Unit and integration tests passing

## Breaking Changes

None

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

---

Merging based on Tadas's approval review, and only making minor docs changes on top of that.
2025-08-18 15:49:04 +01:00

514 lines
16 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
"github.com/modelcontextprotocol/registry/tools/publisher/auth/github"
"github.com/modelcontextprotocol/registry/tools/publisher/auth/none"
)
// 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"`
Status string `json:"status,omitempty"`
Repository Repository `json:"repository"`
VersionDetail VersionDetail `json:"version_detail"`
Packages []Package `json:"packages"`
}
func main() {
if len(os.Args) < 2 {
printUsage()
return
}
var err error
switch os.Args[1] {
case "publish":
err = publishCommand()
case "create":
err = createCommand()
default:
printUsage()
}
if err != nil {
log.Fatal(err)
}
}
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 <command> --help' for more information about a command.\n")
}
func publishCommand() error {
publishFlags := flag.NewFlagSet("publish", flag.ExitOnError)
var registryURL string
var mcpFilePath string
var forceLogin bool
var authMethod string
// Command-line flags for configuration
publishFlags.StringVar(&registryURL, "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",
"authentication method (github for authenticated, none for anonymous) (default: github)")
// 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 (github for authenticated, none for anonymous) (default: github)\n")
}
if err := publishFlags.Parse(os.Args[2:]); err != nil {
log.Fatalf("Error parsing flags: %v", err)
}
if registryURL == "" || mcpFilePath == "" {
publishFlags.Usage()
return errors.New("registry-url and mcp-file are required")
}
// Read MCP file
mcpData, err := os.ReadFile(mcpFilePath)
if err != nil {
return fmt.Errorf("error reading MCP file: %w", err)
}
var authProvider auth.Provider // Determine the authentication method
switch authMethod {
case "github":
log.Println("Using GitHub OAuth for authentication")
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
case "none":
log.Println("Using anonymous authentication")
authProvider = none.NewProvider(registryURL)
default:
return fmt.Errorf("unsupported authentication method: %s", authMethod)
}
// Check if login is needed and perform authentication
ctx := context.Background()
if authProvider.NeedsLogin() {
err := authProvider.Login(ctx)
if err != nil {
return fmt.Errorf("failed to authenticate with %s: %w", authProvider.Name(), err)
}
}
// Get the token
token, err := authProvider.GetToken(ctx)
if err != nil {
return fmt.Errorf("error getting token from %s: %w", authProvider.Name(), err)
}
// Publish to registry
err = publishToRegistry(registryURL, mcpData, token)
if err != nil {
return fmt.Errorf("failed to publish to registry: %w", err)
}
log.Println("Successfully published to registry!")
return nil
}
func createCommand() error {
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
var status 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(&status, "status", "active", "Server status (active or deprecated)")
createFlags.StringVar(&registryName, "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, " --status string Server status (active or deprecated) (default: active)\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 == "" {
return errors.New("--name/-n is required")
}
if description == "" {
return errors.New("--description/-d is required")
}
if repoURL == "" {
return errors.New("--repo-url is required")
}
// Validate status field
if status != "active" && status != "deprecated" {
return errors.New("--status must be either 'active' or 'deprecated'")
}
// Set defaults
if packageName == "" {
packageName = name
}
if packageVersion == "" {
packageVersion = version
}
// Set runtime hint based on registry name if not explicitly provided
if runtimeHint == "" {
switch registryName {
case "docker":
runtimeHint = "docker"
case "npm":
runtimeHint = "npx"
}
}
// Create server structure
server := createServerStructure(name, description, version, repoURL, repoSource,
registryName, packageName, packageVersion, runtimeHint, execute, envVars, packageArgs, status)
// Convert to JSON
jsonData, err := json.MarshalIndent(server, "", " ")
if err != nil {
return fmt.Errorf("error marshaling JSON: %w", err)
}
// Write to file
err = os.WriteFile(output, jsonData, 0600)
if err != nil {
return fmt.Errorf("error writing file: %w", 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")
return nil
}
// 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
var mcpDetails map[string]any
err := json.Unmarshal(mcpData, &mcpDetails)
if err != nil {
return fmt.Errorf("error parsing server.json file: %w", err)
}
// Create the publish request payload (without authentication)
publishReq := mcpDetails
// Convert the request to JSON
jsonData, err := json.Marshal(publishReq)
if err != nil {
return fmt.Errorf("error serializing request: %w", err)
}
// Ensure the URL ends with the publish endpoint
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
publishURL := registryURL + "v0/publish"
// Create and send the 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 and check the 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("publication failed with status %d: %s", resp.StatusCode, body)
}
log.Println(string(body))
return nil
}
func createServerStructure(
name, description, version, repoURL, repoSource, registryName,
packageName, packageVersion, runtimeHint, execute string,
envVars []string, packageArgs []string, status 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,
Status: status,
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
}