This is a first PR for #159 - adding some validation scripts. I want to get to a place where we are confident that making schema changes is in sync with the rest of our code. Step one is making sure the schemas are in sync with our examples. Step two will be making sure the schemas are in sync with our actual Registry implementation code. This is just for the JSON right now; if we're feeling good about this approach I'll do an analogous change for the OpenAPI schemas as well. I'll also follow with adding usage of these scripts to our CI pipeline so we have automated checks on them with every change. ## Summary - Added CLI validation tools for JSON schemas and examples in `docs/server-json/` - Fixed some minor validation errors found in `examples.md` - Ensures schema consistency and example validity in CI/CD pipelines ## Changes ### New Validation Tools 1. **`tools/validate-schemas`** - Validates that `schema.json` and `registry-schema.json` are valid JSON Schema documents 2. **`tools/validate-examples`** - Validates all JSON examples in `examples.md` against both schemas ### Key Features - ✅ Validates JSON schema syntax and structure - ✅ Validates all examples against both base and registry schemas - ✅ Tracks validation counts to prevent silent failures - ✅ Proper exit codes for CI integration - ✅ Clear error messages for debugging ### Fixes to examples.md - Added missing `repository.id` field in NuGet example - Changed `repository.source` from "gitlab" to "github" in Docker example ### Usage ```bash # From repository root ./tools/validate-schemas.sh ./tools/validate-examples.sh ``` ## Testing I ran these scripts locally with minor (breaking) changes, which they each correctly reported. As submitted, they are both passing. 🤖 Co-authored with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Charles Lowell <10964656+chlowell@users.noreply.github.com>
480 lines
15 KiB
Go
480 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
|
|
"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 <command> --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
|
|
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)")
|
|
|
|
// 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 == "" {
|
|
publishFlags.Usage()
|
|
return
|
|
}
|
|
|
|
// Read MCP file
|
|
mcpData, err := os.ReadFile(mcpFilePath)
|
|
if err != nil {
|
|
log.Printf("Error reading MCP file: %s\n", err.Error())
|
|
return
|
|
}
|
|
|
|
var authProvider auth.Provider // Determine the authentication method
|
|
switch authMethod {
|
|
case "github-oauth":
|
|
log.Println("Using GitHub OAuth for authentication")
|
|
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
|
|
default:
|
|
log.Printf("Unsupported authentication method: %s\n", authMethod)
|
|
return
|
|
}
|
|
|
|
// Check if login is needed and perform authentication
|
|
ctx := context.Background()
|
|
if authProvider.NeedsLogin() {
|
|
err := authProvider.Login(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get the token
|
|
token, err := authProvider.GetToken(ctx)
|
|
if err != nil {
|
|
log.Printf("Error getting token from %s: %s\n", authProvider.Name(), err.Error())
|
|
return
|
|
}
|
|
|
|
// Publish to registry
|
|
err = publishToRegistry(registryURL, mcpData, token)
|
|
if err != nil {
|
|
log.Printf("Failed to publish to registry: %s\n", err.Error())
|
|
return
|
|
}
|
|
|
|
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
|
|
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) 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
|
|
}
|