Tidy up command structure for MCP Publisher (#324)
## Summary - Improved command structure and organization for the MCP Publisher CLI - Enhanced readability and maintainability of publisher commands - Cleaned up publish and edit handler logic This builds on the previous cleanup work to further streamline the publisher interface. This also works as part of https://github.com/modelcontextprotocol/registry/issues/89
This commit is contained in:
parent
edf3929504
commit
b8106340bc
10 changed files with 883 additions and 884 deletions
|
|
@ -20,8 +20,6 @@ import (
|
|||
|
||||
const registryURL = "http://localhost:8080"
|
||||
|
||||
var publishedIDRegex = regexp.MustCompile(`"x-io\.modelcontextprotocol\.registry":\s*\{[^}]*"id":\s*"([^"]+)"`)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
if err := run(); err != nil {
|
||||
|
|
@ -29,68 +27,48 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
func getAnonymousToken() (string, error) {
|
||||
func run() error {
|
||||
examplesPath := filepath.Join("docs", "server-json", "examples.md")
|
||||
examples, err := getExamples(examplesPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to extract examples: %v", err)
|
||||
}
|
||||
log.Printf("Found %d examples in %q\n", len(examples), examplesPath)
|
||||
|
||||
// Set up authentication using the new login workflow
|
||||
err = setupPublisherAuth()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to set up publisher auth: %v", err)
|
||||
}
|
||||
defer cleanupPublisherAuth()
|
||||
|
||||
return publish(examples)
|
||||
}
|
||||
|
||||
func setupPublisherAuth() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registryURL+"/v0/auth/none", nil)
|
||||
cmd := exec.CommandContext(ctx, "./bin/publisher", "login", "none", "--registry", registryURL)
|
||||
cmd.WaitDelay = 100 * time.Millisecond
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get anonymous token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("auth endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("login failed: %s", string(out))
|
||||
}
|
||||
|
||||
var tokenResponse struct {
|
||||
RegistryToken string `json:"registry_token"`
|
||||
ExpiresAt int `json:"expires_at"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResponse.RegistryToken == "" {
|
||||
return "", fmt.Errorf("received empty token from auth endpoint")
|
||||
}
|
||||
|
||||
log.Printf("Got anonymous token (expires at %d)", tokenResponse.ExpiresAt)
|
||||
return tokenResponse.RegistryToken, nil
|
||||
log.Printf("Publisher login successful: %s", strings.TrimSpace(string(out)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func run() error {
|
||||
examplesPath := filepath.Join("docs", "server-json", "examples.md")
|
||||
examples, err := examples(examplesPath)
|
||||
func cleanupPublisherAuth() {
|
||||
// Clean up the token file
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatalf("%q not found; run this test from the repo root", examplesPath)
|
||||
}
|
||||
log.Fatalf("failed to extract examples from %q: %v", examplesPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d examples in %q\n", len(examples), examplesPath)
|
||||
|
||||
// Get anonymous token from the none endpoint
|
||||
token, err := getAnonymousToken()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get anonymous token: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(".mcpregistry_registry_token", []byte(token), 0600); err != nil {
|
||||
log.Fatalf("failed to write token: %v", err)
|
||||
}
|
||||
defer os.Remove(".mcpregistry_registry_token")
|
||||
|
||||
return publish(examples)
|
||||
tokenPath := filepath.Join(homeDir, ".mcp_publisher_token")
|
||||
os.Remove(tokenPath)
|
||||
}
|
||||
|
||||
func publish(examples []example) error {
|
||||
|
|
@ -177,24 +155,95 @@ func publishToRegistry(expected map[string]any, line int) error {
|
|||
func runPublisher(filePath string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "./bin/publisher", "publish", "--mcp-file", filePath, "--registry-url", registryURL, "--auth-method", "none")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "./bin/publisher", "publish", filePath)
|
||||
cmd.WaitDelay = 100 * time.Millisecond
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
|
||||
return "", errors.New("publisher not found; did you run tests/integration/run.sh?")
|
||||
}
|
||||
output := strings.TrimSpace("publisher output:\n\t" + strings.ReplaceAll(string(out), "\n", "\n \t"))
|
||||
if err != nil {
|
||||
return "", errors.New(output)
|
||||
}
|
||||
log.Println(" ✅", output)
|
||||
|
||||
m := publishedIDRegex.FindStringSubmatch(output)
|
||||
if len(m) != 2 || m[1] == "" {
|
||||
return "", errors.New("didn't find ID in publisher output")
|
||||
// Get the server name from the file to look up the ID
|
||||
serverName, err := getServerNameFromFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get server name from file: %w", err)
|
||||
}
|
||||
return m[1], nil
|
||||
|
||||
// Find the server in the registry by name
|
||||
return findServerIDByName(serverName)
|
||||
}
|
||||
|
||||
func getServerNameFromFile(filePath string) (string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var serverData map[string]any
|
||||
if err := json.Unmarshal(data, &serverData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Handle both old ServerDetail format and new PublishRequest format
|
||||
if server, exists := serverData["server"]; exists {
|
||||
// New PublishRequest format
|
||||
if serverMap, ok := server.(map[string]any); ok {
|
||||
if name, ok := serverMap["name"].(string); ok {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
} else if name, ok := serverData["name"].(string); ok {
|
||||
// Old ServerDetail format
|
||||
return name, nil
|
||||
}
|
||||
|
||||
return "", errors.New("could not find server name in file")
|
||||
}
|
||||
|
||||
func findServerIDByName(serverName string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryURL+"/v0/servers", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("registry responded %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var serverList struct {
|
||||
Servers []map[string]any `json:"servers"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&serverList); err != nil {
|
||||
return "", fmt.Errorf("failed to decode server list: %w", err)
|
||||
}
|
||||
|
||||
// Find the server with matching name
|
||||
for _, server := range serverList.Servers {
|
||||
if registryMeta, ok := server["x-io.modelcontextprotocol.registry"].(map[string]any); ok {
|
||||
if id, ok := registryMeta["id"].(string); ok {
|
||||
if serverData, ok := server["server"].(map[string]any); ok {
|
||||
if name, ok := serverData["name"].(string); ok && name == serverName {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find server with name %s", serverName)
|
||||
}
|
||||
|
||||
func verifyPublishedServer(id string, expected map[string]any) error {
|
||||
|
|
@ -221,20 +270,20 @@ func verifyPublishedServer(id string, expected map[string]any) error {
|
|||
if err := json.Unmarshal(content, &actual); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal registry response: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Both API response and expected are now in extension wrapper format
|
||||
// Compare the server portions of both
|
||||
actualServer, ok := actual["server"]
|
||||
if !ok {
|
||||
return fmt.Errorf("expected server field in registry response")
|
||||
}
|
||||
|
||||
|
||||
// Extract expected server portion for comparison
|
||||
expectedServer := expected
|
||||
if server, exists := expected["server"]; exists {
|
||||
expectedServer = server.(map[string]any)
|
||||
}
|
||||
|
||||
|
||||
if err := compare(expectedServer, actualServer); err != nil {
|
||||
return fmt.Errorf(`example "%s": %w`, expectedServer["name"], err)
|
||||
}
|
||||
|
|
@ -246,9 +295,10 @@ type example struct {
|
|||
line int
|
||||
}
|
||||
|
||||
func examples(path string) ([]example, error) {
|
||||
func getExamples(path string) ([]example, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Fatalf("examples not found; run this test from the repo root")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
# MCP Registry Publisher Tool
|
||||
|
||||
The MCP Registry Publisher Tool is designed to publish Model Context Protocol (MCP) server details to an MCP registry. This tool uses GitHub OAuth device flow authentication to securely manage the publishing process.
|
||||
The MCP Registry Publisher Tool helps you publish Model Context Protocol (MCP) servers to the official registry. It follows a simple, npm-like workflow that's familiar to most developers.
|
||||
|
||||
## Building the Tool
|
||||
|
||||
You can build the publisher tool using the provided build script:
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
|
|
@ -16,158 +14,91 @@ You can build the publisher tool using the provided build script:
|
|||
|
||||
The compiled binary will be placed in the `bin` directory.
|
||||
|
||||
## Usage
|
||||
|
||||
The tool supports two main commands:
|
||||
|
||||
### Publishing a server
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Basic usage with GitHub OAuth (interactive authentication)
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE>
|
||||
# 1. Create a template server.json file
|
||||
mcp-publisher init
|
||||
|
||||
# Use GitHub Actions OIDC (for CI/CD pipelines)
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE> --auth-method github-oidc
|
||||
# 2. Edit server.json with your server details
|
||||
|
||||
# Force a new login even if a token exists
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE> --login
|
||||
# 3. Authenticate with the registry (one-time setup)
|
||||
mcp-publisher login <method>
|
||||
|
||||
# 4. Publish your server
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
### Creating a server.json file
|
||||
## Commands
|
||||
|
||||
### `mcp-publisher init`
|
||||
|
||||
Creates a template `server.json` file with smart defaults. This command automatically detects values from your environment when possible.
|
||||
|
||||
```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"
|
||||
mcp-publisher init
|
||||
```
|
||||
|
||||
### Command-line Arguments
|
||||
Auto-detection sources:
|
||||
- **Git remote origin** for repository URL and server name
|
||||
- **package.json** for npm packages (name, description)
|
||||
- **pyproject.toml** for Python packages
|
||||
- **Current directory** for fallback naming
|
||||
|
||||
- `--registry-url`: URL of the MCP registry (required)
|
||||
- `--mcp-file`: Path to the MCP configuration file (required)
|
||||
- `--login`: Force a new GitHub authentication even if a token already exists (overwrites existing token file)
|
||||
- `--auth-method`: Authentication method to use (default: github-at)
|
||||
- `github-at`: Interactive GitHub OAuth device flow authentication
|
||||
- `github-oidc`: GitHub Actions OIDC authentication (for CI/CD)
|
||||
- `dns`: DNS-based public/private key authentication
|
||||
- `http`: HTTP-based public/private key authentication
|
||||
- `none`: No authentication (for registry contributors testing locally)
|
||||
- `--dns-domain`: Domain name for DNS authentication (required for dns auth method)
|
||||
- `--dns-private-key`: 64-character hex seed for DNS authentication (required for dns auth method)
|
||||
- `--http-domain`: Domain name for HTTP authentication (required for http auth method)
|
||||
- `--http-private-key`: 64-character hex seed for HTTP authentication (required for http auth method)
|
||||
The generated file includes placeholder values that you should update before publishing.
|
||||
|
||||
## Creating a server.json file
|
||||
### `mcp-publisher login <method>`
|
||||
|
||||
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
|
||||
Authenticates with the MCP registry. You'll only need to do this once - the tool saves your credentials locally.
|
||||
|
||||
```bash
|
||||
./bin/mcp-publisher create [flags]
|
||||
# GitHub interactive authentication
|
||||
mcp-publisher login github
|
||||
|
||||
# GitHub Actions OIDC (for CI/CD)
|
||||
mcp-publisher login github-oidc
|
||||
|
||||
# DNS-based authentication
|
||||
mcp-publisher login dns --domain example.com --private-key abc123...
|
||||
|
||||
# HTTP-based authentication
|
||||
mcp-publisher login http --domain example.com --private-key abc123...
|
||||
```
|
||||
|
||||
### Create Command Flags
|
||||
**Authentication Methods:**
|
||||
|
||||
#### Required Flags
|
||||
- `--name`, `-n`: Server name (e.g., io.github.owner/repo-name)
|
||||
- `--description`, `-d`: Server description
|
||||
- `--repo-url`: Repository URL
|
||||
- **`github`**: Interactive GitHub OAuth flow. Opens browser for authentication.
|
||||
- **`github-oidc`**: For GitHub Actions. Uses OIDC tokens automatically.
|
||||
- **`dns`**: Domain-based auth using DNS TXT records. Requires `--domain` and `--private-key`.
|
||||
- **`http`**: Domain-based auth using HTTPS endpoint. Requires `--domain` and `--private-key`.
|
||||
|
||||
#### 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)
|
||||
### `mcp-publisher publish`
|
||||
|
||||
### Create Examples
|
||||
|
||||
#### Basic NPX Server
|
||||
Publishes your `server.json` file to the registry. You must be logged in first.
|
||||
|
||||
```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"
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
#### Docker Server
|
||||
This command:
|
||||
- Validates your `server.json` file
|
||||
- Checks your authentication
|
||||
- Uploads the server configuration to the registry
|
||||
|
||||
### `mcp-publisher logout`
|
||||
|
||||
Clears your saved authentication credentials.
|
||||
|
||||
```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"
|
||||
mcp-publisher logout
|
||||
```
|
||||
|
||||
#### Server with Package Arguments
|
||||
## GitHub Actions
|
||||
|
||||
```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 supports multiple authentication methods to accommodate different use cases:
|
||||
|
||||
### GitHub OAuth Device Flow (`github-at`) - Default
|
||||
|
||||
For interactive use:
|
||||
|
||||
1. **Automatic Setup**: The tool automatically retrieves the GitHub Client ID from the registry's health endpoint
|
||||
2. **First Run Authentication**: When first run (or with the `--login` flag), the tool initiates the GitHub device flow
|
||||
3. **User Authorization**: You'll be provided with a URL and a verification code to enter on GitHub
|
||||
4. **Token Storage**: After successful authentication, the tool saves the access token locally in `.mcpregistry_github_token` for future use
|
||||
5. **Token Exchange**: The GitHub token is exchanged for a short-lived registry token, which is saved locally in `.mcpregistry_registry_token`
|
||||
6. **Secure Communication**: The registry token is sent in the HTTP Authorization header with the Bearer scheme for all registry API calls
|
||||
|
||||
```bash
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE> --auth-method github-at
|
||||
```
|
||||
|
||||
### GitHub Actions OIDC (`github-oidc`)
|
||||
|
||||
For CI/CD pipelines using GitHub Actions:
|
||||
|
||||
1. **Prerequisites**: Your GitHub Actions workflow must have `id-token: write` permissions
|
||||
2. **Token Exchange**: The OIDC token is exchanged directly for a registry token via `/v0/auth/github-oidc`
|
||||
3. **No Storage**: No local token files are created; authentication is ephemeral per workflow run
|
||||
|
||||
**Example GitHub Actions workflow:**
|
||||
For automated publishing from GitHub Actions:
|
||||
|
||||
```yaml
|
||||
name: Publish MCP Server
|
||||
name: Publish to MCP Registry
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
|
@ -176,72 +107,112 @@ jobs:
|
|||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC token generation
|
||||
contents: read # E.g. To read the server.json file from your Git repo
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish to MCP Registry
|
||||
|
||||
- name: Setup MCP Publisher
|
||||
run: |
|
||||
./bin/mcp-publisher publish \
|
||||
--registry-url "https://registry.modelcontextprotocol.org" \
|
||||
--mcp-file "./server.json" \
|
||||
--auth-method github-oidc
|
||||
curl -LO https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher-linux-amd64
|
||||
chmod +x mcp-publisher-linux-amd64
|
||||
|
||||
- name: Login to Registry
|
||||
run: ./mcp-publisher-linux-amd64 login github-oidc
|
||||
|
||||
- name: Publish to Registry
|
||||
run: ./mcp-publisher-linux-amd64 publish
|
||||
```
|
||||
|
||||
### DNS Authentication (`dns`)
|
||||
The `login github-oidc` command uses GitHub's OIDC tokens for authentication in CI environments.
|
||||
|
||||
For domain-based authentication using public/private key cryptography:
|
||||
## Examples
|
||||
|
||||
1. **Generate Ed25519 keypair**:
|
||||
### Publishing an NPM-based server
|
||||
|
||||
```bash
|
||||
# Create template server.json (auto-detects from package.json)
|
||||
# Then edit server.json to add your specific details
|
||||
mcp-publisher init
|
||||
|
||||
# Login (first time only)
|
||||
mcp-publisher login github
|
||||
|
||||
# Publish
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
### Publishing a Python server
|
||||
|
||||
```bash
|
||||
# Create template server.json (auto-detects from pyproject.toml)
|
||||
# Then edit server.json to add your specific details
|
||||
mcp-publisher init
|
||||
|
||||
# Publish (assuming already logged in)
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
### Publishing with DNS authentication
|
||||
|
||||
```bash
|
||||
# Setup DNS TXT record first (see DNS Authentication section below)
|
||||
|
||||
# Login with DNS
|
||||
mcp-publisher login dns --domain example.com --private-key YOUR_PRIVATE_KEY
|
||||
|
||||
# Publish
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
## Authentication Details
|
||||
|
||||
### DNS Authentication
|
||||
|
||||
For domain-based authentication using DNS:
|
||||
|
||||
1. Generate an Ed25519 keypair:
|
||||
```bash
|
||||
openssl genpkey -algorithm Ed25519 -out /tmp/key.pem && \
|
||||
echo "\n\nDNS record to add to your domain:" && \
|
||||
echo " Type: TXT" && \
|
||||
echo " Value: v=MCPv1; k=ed25519; p=$(openssl pkey -in /tmp/key.pem -pubout -outform DER | tail -c 32 | base64)" && \
|
||||
echo "DNS TXT Record:" && \
|
||||
echo " v=MCPv1; k=ed25519; p=$(openssl pkey -in /tmp/key.pem -pubout -outform DER | tail -c 32 | base64)" && \
|
||||
echo "" && \
|
||||
echo "Private key for --dns-private-key flag:" && \
|
||||
echo " $(openssl pkey -in /tmp/key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')\n" && \
|
||||
echo "Private key for login:" && \
|
||||
echo " $(openssl pkey -in /tmp/key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')" && \
|
||||
rm /tmp/key.pem
|
||||
```
|
||||
2. **Add DNS TXT record**: Add a TXT record to your domain with format: `v=MCPv1; k=ed25519; p=<base64-public-key>`
|
||||
3. **Use CLI arguments**: Provide domain and private key via command line flags
|
||||
|
||||
```bash
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE> \
|
||||
--auth-method dns --dns-domain example.com --dns-private-key abc123...
|
||||
```
|
||||
2. Add the TXT record to your domain's DNS
|
||||
|
||||
This grants publishing permissions for both `example.com/*` and `*.example.com/*` namespaces.
|
||||
|
||||
### HTTP Authentication (`http`)
|
||||
|
||||
For domain-based authentication using HTTP-hosted public keys:
|
||||
|
||||
1. **Generate Ed25519 keypair**:
|
||||
3. Login with:
|
||||
```bash
|
||||
openssl genpkey -algorithm Ed25519 -out /tmp/key.pem && \
|
||||
echo "\n\nFile to host on your domain:" && \
|
||||
echo " URL: https://yoursite.com/.well-known/mcp-registry-auth" && \
|
||||
echo " Content: v=MCPv1; k=ed25519; p=$(openssl pkey -in /tmp/key.pem -pubout -outform DER | tail -c 32 | base64)" && \
|
||||
echo "" && \
|
||||
echo "Private key for --http-private-key flag:" && \
|
||||
echo " $(openssl pkey -in /tmp/key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')\n" && \
|
||||
rm /tmp/key.pem
|
||||
mcp-publisher login dns --domain example.com --private-key YOUR_PRIVATE_KEY
|
||||
```
|
||||
2. **Host public key**: Create an HTTP endpoint at `https://yoursite.com/.well-known/mcp-registry-auth` that returns: `v=MCPv1; k=ed25519; p=<base64-public-key>`
|
||||
3. **Use CLI arguments**: Provide domain and private key via command line flags
|
||||
|
||||
```bash
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE> \
|
||||
--auth-method http --http-domain example.com --http-private-key abc123...
|
||||
```
|
||||
This grants publishing rights for `example.com/*` and `*.example.com/*` namespaces.
|
||||
|
||||
This grants publishing permissions for the `example.com/*` namespace.
|
||||
### HTTP Authentication
|
||||
|
||||
### No Authentication (`none`)
|
||||
For HTTP-based authentication:
|
||||
|
||||
Mainly for registry contributors, for testing locally:
|
||||
1. Generate an Ed25519 keypair (same as DNS)
|
||||
|
||||
```bash
|
||||
./bin/mcp-publisher publish --registry-url <REGISTRY_URL> --mcp-file <PATH_TO_MCP_FILE> --auth-method none
|
||||
```
|
||||
2. Host the public key at `https://yoursite.com/.well-known/mcp-registry-auth`:
|
||||
```
|
||||
v=MCPv1; k=ed25519; p=YOUR_PUBLIC_KEY_BASE64
|
||||
```
|
||||
|
||||
3. Login with:
|
||||
```bash
|
||||
mcp-publisher login http --domain example.com --private-key YOUR_PRIVATE_KEY
|
||||
```
|
||||
|
||||
This grants publishing rights for the `example.com/*` namespace.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Not authenticated" error**: Run `mcp-publisher login <method>` to authenticate (e.g., `mcp-publisher login github`).
|
||||
|
||||
**"Invalid server.json" error**: Ensure your `server.json` file is valid. You can recreate it with `mcp-publisher init`.
|
||||
|
||||
**GitHub Actions failing**: Ensure your workflow has `id-token: write` permission and you're using `mcp-publisher login github-oidc`.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
|
@ -141,13 +140,13 @@ func (g *GitHubATProvider) Login(ctx context.Context) error {
|
|||
}
|
||||
|
||||
// Display instructions to the user
|
||||
log.Println("\nTo authenticate, please:")
|
||||
log.Println("1. Go to:", verificationURI)
|
||||
log.Println("2. Enter code:", userCode)
|
||||
log.Println("3. Authorize this application")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "\nTo authenticate, please:")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "1. Go to:", verificationURI)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "2. Enter code:", userCode)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "3. Authorize this application")
|
||||
|
||||
// Poll for the token
|
||||
log.Println("Waiting for authorization...")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Waiting for authorization...")
|
||||
token, err := g.pollForToken(ctx, deviceCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error polling for token: %w", err)
|
||||
|
|
@ -159,7 +158,7 @@ func (g *GitHubATProvider) Login(ctx context.Context) error {
|
|||
return fmt.Errorf("error saving token: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Successfully authenticated!")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Successfully authenticated!")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -310,31 +309,26 @@ func getClientID(ctx context.Context, registryURL string) (string, error) {
|
|||
healthURL := registryURL + "/v0/health"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %s\n", err.Error())
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching health endpoint: %s\n", err.Error())
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("Health endpoint returned status %d: %s\n", resp.StatusCode, body)
|
||||
return "", fmt.Errorf("health endpoint returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var healthResponse ServerHealthResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&healthResponse)
|
||||
if err != nil {
|
||||
log.Printf("Error decoding health response: %s\n", err.Error())
|
||||
return "", err
|
||||
}
|
||||
if healthResponse.GitHubClientID == "" {
|
||||
log.Println("GitHub Client ID is not set in the server's health response.")
|
||||
return "", fmt.Errorf("GitHub Client ID is not set in the server's health response")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ go build -o "$OUTPUT_DIR/$BINARY_NAME" .
|
|||
# Make the binary executable
|
||||
chmod +x "$OUTPUT_DIR/$BINARY_NAME"
|
||||
|
||||
echo "Build complete: $OUTPUT_DIR/$BINARY_NAME"
|
||||
echo "Build complete: $(realpath $OUTPUT_DIR/$BINARY_NAME)"
|
||||
|
||||
# Optional: Build for multiple platforms
|
||||
if [ "$1" == "--all" ]; then
|
||||
|
|
@ -37,5 +37,5 @@ if [ "$1" == "--all" ]; then
|
|||
# Windows AMD64
|
||||
GOOS=windows GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-windows-amd64.exe" .
|
||||
|
||||
echo "Multi-platform build complete in $OUTPUT_DIR/"
|
||||
echo "Multi-platform build complete in $(realpath $OUTPUT_DIR)"
|
||||
fi
|
||||
|
|
|
|||
319
tools/publisher/commands/init.go
Normal file
319
tools/publisher/commands/init.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||
)
|
||||
|
||||
const (
|
||||
RegistryTypeNPM = "npm"
|
||||
RegistryTypePyPi = "pypi"
|
||||
RegistryTypeDocker = "docker"
|
||||
)
|
||||
|
||||
func InitCommand() error {
|
||||
// Check if server.json already exists
|
||||
if _, err := os.Stat("server.json"); err == nil {
|
||||
return errors.New("server.json already exists")
|
||||
}
|
||||
|
||||
// Try to detect values from environment
|
||||
name := detectServerName()
|
||||
description := detectDescription()
|
||||
version := "1.0.0"
|
||||
repoURL := detectRepoURL()
|
||||
repoSource := "github"
|
||||
if repoURL != "" && !strings.Contains(repoURL, "github.com") {
|
||||
if strings.Contains(repoURL, "gitlab.com") {
|
||||
repoSource = "gitlab"
|
||||
} else {
|
||||
repoSource = "git"
|
||||
}
|
||||
}
|
||||
|
||||
packageType := detectPackageType()
|
||||
packageIdentifier := detectPackageIdentifier(name, packageType)
|
||||
|
||||
// Create example environment variables
|
||||
envVars := []model.KeyValueInput{
|
||||
{
|
||||
Name: "YOUR_API_KEY",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Your API key for the service",
|
||||
IsRequired: true,
|
||||
IsSecret: true,
|
||||
Format: model.FormatString,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create the server structure
|
||||
server := createServerJSON(
|
||||
name, description, version, repoURL, repoSource,
|
||||
packageType, packageIdentifier, version, envVars,
|
||||
)
|
||||
|
||||
// Write to file
|
||||
jsonData, err := json.MarshalIndent(server, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling JSON: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile("server.json", jsonData, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing file: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Created server.json")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "\nEdit server.json to update:")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " • Server name and description")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " • Package details")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " • Environment variables")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "\nThen publish with:")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github # or your preferred auth method")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getNameFromPackageJSON() string {
|
||||
data, err := os.ReadFile("package.json")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var pkg map[string]any
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
name, ok := pkg["name"].(string)
|
||||
if !ok || name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Convert npm package name to MCP server name
|
||||
// @org/package -> io.npm.org/package
|
||||
if strings.HasPrefix(name, "@") {
|
||||
parts := strings.Split(name[1:], "/")
|
||||
if len(parts) == 2 {
|
||||
return fmt.Sprintf("io.github.%s/%s", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("io.github.<your-username>/%s", name)
|
||||
}
|
||||
|
||||
func detectServerName() string {
|
||||
// Try to get from git remote
|
||||
repoURL := detectRepoURL()
|
||||
if repoURL != "" {
|
||||
// Extract owner/repo from GitHub URL
|
||||
if strings.Contains(repoURL, "github.com") {
|
||||
parts := strings.Split(repoURL, "/")
|
||||
if len(parts) >= 5 {
|
||||
owner := parts[3]
|
||||
repo := strings.TrimSuffix(parts[4], ".git")
|
||||
return fmt.Sprintf("io.github.%s/%s", owner, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get from package.json
|
||||
name := getNameFromPackageJSON()
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
// Use current directory name as fallback
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return fmt.Sprintf("com.example/%s", filepath.Base(cwd))
|
||||
}
|
||||
|
||||
return "com.example/my-mcp-server"
|
||||
}
|
||||
|
||||
func detectDescription() string {
|
||||
// Try to get from package.json
|
||||
if data, err := os.ReadFile("package.json"); err == nil {
|
||||
var pkg map[string]any
|
||||
if json.Unmarshal(data, &pkg) == nil {
|
||||
if desc, ok := pkg["description"].(string); ok && desc != "" {
|
||||
return desc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "An MCP server that provides [describe what your server does]"
|
||||
}
|
||||
|
||||
func detectRepoURL() string {
|
||||
// Try git remote
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
|
||||
if output, err := cmd.Output(); err == nil {
|
||||
url := strings.TrimSpace(string(output))
|
||||
// Convert SSH URL to HTTPS if needed
|
||||
if strings.HasPrefix(url, "git@github.com:") {
|
||||
url = strings.Replace(url, "git@github.com:", "https://github.com/", 1)
|
||||
}
|
||||
url = strings.TrimSuffix(url, ".git")
|
||||
return url
|
||||
}
|
||||
|
||||
// Try package.json repository field
|
||||
if data, err := os.ReadFile("package.json"); err == nil {
|
||||
var pkg map[string]any
|
||||
if json.Unmarshal(data, &pkg) == nil {
|
||||
if repo, ok := pkg["repository"].(map[string]any); ok {
|
||||
if url, ok := repo["url"].(string); ok {
|
||||
return strings.TrimSuffix(url, ".git")
|
||||
}
|
||||
}
|
||||
if repo, ok := pkg["repository"].(string); ok {
|
||||
return strings.TrimSuffix(repo, ".git")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "https://github.com/YOUR_USERNAME/YOUR_REPO"
|
||||
}
|
||||
|
||||
func detectPackageType() string {
|
||||
// Check for package.json
|
||||
if _, err := os.Stat("package.json"); err == nil {
|
||||
return RegistryTypeNPM
|
||||
}
|
||||
|
||||
// Check for pyproject.toml or setup.py
|
||||
if _, err := os.Stat("pyproject.toml"); err == nil {
|
||||
return RegistryTypePyPi
|
||||
}
|
||||
if _, err := os.Stat("setup.py"); err == nil {
|
||||
return RegistryTypePyPi
|
||||
}
|
||||
|
||||
// Check for Dockerfile
|
||||
if _, err := os.Stat("Dockerfile"); err == nil {
|
||||
return RegistryTypeDocker
|
||||
}
|
||||
|
||||
// Default to npm as most common
|
||||
return RegistryTypeNPM
|
||||
}
|
||||
|
||||
func detectPackageIdentifier(serverName string, packageType string) string {
|
||||
switch packageType {
|
||||
case RegistryTypeNPM:
|
||||
// Try to get from package.json
|
||||
if data, err := os.ReadFile("package.json"); err == nil {
|
||||
var pkg map[string]any
|
||||
if json.Unmarshal(data, &pkg) == nil {
|
||||
if name, ok := pkg["name"].(string); ok && name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert server name to npm package name
|
||||
if strings.HasPrefix(serverName, "io.github.") {
|
||||
parts := strings.Split(serverName, "/")
|
||||
if len(parts) == 2 {
|
||||
owner := strings.TrimPrefix(parts[0], "io.github.")
|
||||
return fmt.Sprintf("@%s/%s", owner, parts[1])
|
||||
}
|
||||
}
|
||||
return "@your-org/your-package"
|
||||
|
||||
case "pypi":
|
||||
// Try to get from pyproject.toml or setup.py
|
||||
if data, err := os.ReadFile("pyproject.toml"); err == nil {
|
||||
// Simple extraction - could be improved with proper TOML parser
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "name") && strings.Contains(line, "=") {
|
||||
parts := strings.Split(line, "=")
|
||||
if len(parts) >= 2 {
|
||||
name := strings.Trim(parts[1], " \"'")
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "your-package"
|
||||
|
||||
case "docker":
|
||||
// Use a sensible default
|
||||
if strings.Contains(serverName, "/") {
|
||||
parts := strings.Split(serverName, "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return "your-image"
|
||||
|
||||
default:
|
||||
return "your-package"
|
||||
}
|
||||
}
|
||||
|
||||
func createServerJSON(
|
||||
name, description, version, repoURL, repoSource,
|
||||
packageType, packageIdentifier, packageVersion string,
|
||||
envVars []model.KeyValueInput,
|
||||
) model.ServerJSON {
|
||||
// Determine registry type and base URL
|
||||
var registryType, registryBaseURL string
|
||||
switch packageType {
|
||||
case RegistryTypeNPM:
|
||||
registryType = RegistryTypeNPM
|
||||
registryBaseURL = "https://registry.npmjs.org"
|
||||
case RegistryTypePyPi:
|
||||
registryType = RegistryTypePyPi
|
||||
registryBaseURL = "https://pypi.org"
|
||||
case RegistryTypeDocker:
|
||||
registryType = "docker-hub"
|
||||
registryBaseURL = "https://docker.io"
|
||||
case "url":
|
||||
registryType = "url"
|
||||
registryBaseURL = ""
|
||||
default:
|
||||
registryType = packageType
|
||||
registryBaseURL = ""
|
||||
}
|
||||
|
||||
// Create package
|
||||
pkg := model.Package{
|
||||
RegistryType: registryType,
|
||||
RegistryBaseURL: registryBaseURL,
|
||||
Identifier: packageIdentifier,
|
||||
Version: packageVersion,
|
||||
EnvironmentVariables: envVars,
|
||||
}
|
||||
|
||||
// Create server structure
|
||||
return model.ServerJSON{
|
||||
Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
||||
Name: name,
|
||||
Description: description,
|
||||
Status: model.StatusActive,
|
||||
Repository: model.Repository{
|
||||
URL: repoURL,
|
||||
Source: repoSource,
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: version,
|
||||
},
|
||||
Packages: []model.Package{pkg},
|
||||
}
|
||||
}
|
||||
105
tools/publisher/commands/login.go
Normal file
105
tools/publisher/commands/login.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultRegistryURL = "https://registry.modelcontextprotocol.io"
|
||||
TokenFileName = ".mcp_publisher_token" //nolint:gosec // Not a credential, just a filename
|
||||
)
|
||||
|
||||
func LoginCommand(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("authentication method required\n\nUsage: mcp-publisher login <method>\n\nMethods:\n github Interactive GitHub authentication\n github-oidc GitHub Actions OIDC authentication\n dns DNS-based authentication (requires --domain and --private-key)\n http HTTP-based authentication (requires --domain and --private-key)\n none Anonymous authentication (for testing)")
|
||||
}
|
||||
|
||||
method := args[0]
|
||||
|
||||
// Parse remaining flags based on method
|
||||
loginFlags := flag.NewFlagSet("login", flag.ExitOnError)
|
||||
var domain string
|
||||
var privateKey string
|
||||
var registryURL string
|
||||
|
||||
loginFlags.StringVar(®istryURL, "registry", DefaultRegistryURL, "Registry URL")
|
||||
|
||||
if method == "dns" || method == "http" {
|
||||
loginFlags.StringVar(&domain, "domain", "", "Domain name")
|
||||
loginFlags.StringVar(&privateKey, "private-key", "", "Private key (64-char hex)")
|
||||
}
|
||||
|
||||
if err := loginFlags.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create auth provider based on method
|
||||
var authProvider auth.Provider
|
||||
switch method {
|
||||
case "github":
|
||||
authProvider = auth.NewGitHubATProvider(true, registryURL)
|
||||
case "github-oidc":
|
||||
authProvider = auth.NewGitHubOIDCProvider(registryURL)
|
||||
case "dns":
|
||||
if domain == "" || privateKey == "" {
|
||||
return errors.New("dns authentication requires --domain and --private-key")
|
||||
}
|
||||
authProvider = auth.NewDNSProvider(registryURL, domain, privateKey)
|
||||
case "http":
|
||||
if domain == "" || privateKey == "" {
|
||||
return errors.New("http authentication requires --domain and --private-key")
|
||||
}
|
||||
authProvider = auth.NewHTTPProvider(registryURL, domain, privateKey)
|
||||
case "none":
|
||||
authProvider = auth.NewNoneProvider(registryURL)
|
||||
default:
|
||||
return fmt.Errorf("unknown authentication method: %s\nFor a list of available methods, run: mcp-publisher login", method)
|
||||
}
|
||||
|
||||
// Perform login
|
||||
ctx := context.Background()
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Logging in with %s...\n", method)
|
||||
|
||||
if err := authProvider.Login(ctx); err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
// Get and save token
|
||||
token, err := authProvider.GetToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
|
||||
// Save token to file
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
tokenPath := filepath.Join(homeDir, TokenFileName)
|
||||
tokenData := map[string]string{
|
||||
"token": token,
|
||||
"method": method,
|
||||
"registry": registryURL,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(tokenData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal token data: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(tokenPath, jsonData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to save token: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged in")
|
||||
return nil
|
||||
}
|
||||
43
tools/publisher/commands/logout.go
Normal file
43
tools/publisher/commands/logout.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func LogoutCommand() error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
tokenPath := filepath.Join(homeDir, TokenFileName)
|
||||
|
||||
// Check if token file exists
|
||||
if _, err := os.Stat(tokenPath); os.IsNotExist(err) {
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Not logged in")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove token file
|
||||
if err := os.Remove(tokenPath); err != nil {
|
||||
return fmt.Errorf("failed to remove token: %w", err)
|
||||
}
|
||||
|
||||
// Also clean up legacy token files if they exist
|
||||
legacyFiles := []string{
|
||||
".mcpregistry_github_token",
|
||||
".mcpregistry_registry_token",
|
||||
}
|
||||
|
||||
for _, file := range legacyFiles {
|
||||
path := filepath.Join(homeDir, file)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
os.Remove(path) // Ignore errors for legacy files
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged out")
|
||||
return nil
|
||||
}
|
||||
132
tools/publisher/commands/publish.go
Normal file
132
tools/publisher/commands/publish.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PublishCommand(args []string) error {
|
||||
// Check for server.json file
|
||||
serverFile := "server.json"
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
serverFile = args[0]
|
||||
}
|
||||
|
||||
// Read server.json
|
||||
serverData, err := os.ReadFile(serverFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("server.json not found. Run 'mcp-publisher init' to create one")
|
||||
}
|
||||
return fmt.Errorf("failed to read server.json: %w", err)
|
||||
}
|
||||
|
||||
// Validate JSON
|
||||
var serverJSON map[string]any
|
||||
if err := json.Unmarshal(serverData, &serverJSON); err != nil {
|
||||
return fmt.Errorf("invalid server.json: %w", err)
|
||||
}
|
||||
|
||||
// Load saved token
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
tokenPath := filepath.Join(homeDir, TokenFileName)
|
||||
tokenData, err := os.ReadFile(tokenPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New("not authenticated. Run 'mcp-publisher login <method>' first")
|
||||
}
|
||||
return fmt.Errorf("failed to read token: %w", err)
|
||||
}
|
||||
|
||||
var tokenInfo map[string]string
|
||||
if err := json.Unmarshal(tokenData, &tokenInfo); err != nil {
|
||||
return fmt.Errorf("invalid token data: %w", err)
|
||||
}
|
||||
|
||||
token := tokenInfo["token"]
|
||||
registryURL := tokenInfo["registry"]
|
||||
if registryURL == "" {
|
||||
registryURL = DefaultRegistryURL
|
||||
}
|
||||
|
||||
// Publish to registry
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Publishing to %s...\n", registryURL)
|
||||
if err := publishToRegistry(registryURL, serverData, token); err != nil {
|
||||
return fmt.Errorf("publish failed: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully published")
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishToRegistry(registryURL string, serverData []byte, token string) error {
|
||||
// Parse the server JSON data
|
||||
var serverDetails map[string]any
|
||||
err := json.Unmarshal(serverData, &serverDetails)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing server.json file: %w", err)
|
||||
}
|
||||
|
||||
// Create the publish request payload
|
||||
var publishReq map[string]any
|
||||
if _, hasServerField := serverDetails["server"]; hasServerField {
|
||||
// Already in PublishRequest format
|
||||
publishReq = serverDetails
|
||||
} else {
|
||||
// Wrap in server field
|
||||
publishReq = map[string]any{
|
||||
"server": serverDetails,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error serializing request: %w", err)
|
||||
}
|
||||
|
||||
// Ensure URL ends with the publish endpoint
|
||||
if !strings.HasSuffix(registryURL, "/") {
|
||||
registryURL += "/"
|
||||
}
|
||||
publishURL := registryURL + "v0/publish"
|
||||
|
||||
// Create and send request
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, publishURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,606 +1,53 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
|
||||
"github.com/modelcontextprotocol/registry/tools/publisher/commands"
|
||||
)
|
||||
|
||||
// Package type constants
|
||||
const (
|
||||
packageTypeDocker = "docker"
|
||||
registryNPM = "npm"
|
||||
registryPyPI = "pypi"
|
||||
registryDocker = "docker"
|
||||
registryNuGet = "nuget"
|
||||
registryURL = "url"
|
||||
)
|
||||
|
||||
// Server structure types for JSON generation
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
return
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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 = publishCommand()
|
||||
case "create":
|
||||
err = createCommand()
|
||||
default:
|
||||
err = commands.PublishCommand(os.Args[2:])
|
||||
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 {
|
||||
log.Fatal(err)
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
var dnsDomain string
|
||||
var dnsPrivateKey string
|
||||
var httpDomain string
|
||||
var httpPrivateKey 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-at", "authentication method (default: github-at)")
|
||||
publishFlags.StringVar(&dnsDomain, "dns-domain", "", "domain name for DNS authentication (required for dns auth method)")
|
||||
publishFlags.StringVar(&dnsPrivateKey, "dns-private-key", "", "64-character hex seed for DNS authentication (required for dns auth method)")
|
||||
publishFlags.StringVar(&httpDomain, "http-domain", "", "domain name for HTTP authentication (required for http auth method)")
|
||||
publishFlags.StringVar(&httpPrivateKey, "http-private-key", "", "64-character hex seed for HTTP authentication (required for http auth method)")
|
||||
|
||||
// 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 (default: github-at)\n")
|
||||
fmt.Fprint(os.Stdout, " --dns-domain string domain name for DNS authentication\n")
|
||||
fmt.Fprint(os.Stdout, " --dns-private-key string 64-character hex seed for DNS authentication\n")
|
||||
fmt.Fprint(os.Stdout, " --http-domain string domain name for HTTP authentication\n")
|
||||
fmt.Fprint(os.Stdout, " --http-private-key string 64-character hex seed for HTTP authentication\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-at":
|
||||
log.Println("Using GitHub Access Token for authentication")
|
||||
authProvider = auth.NewGitHubATProvider(forceLogin, registryURL)
|
||||
case "github-oidc":
|
||||
log.Println("Using GitHub Actions OIDC for authentication")
|
||||
authProvider = auth.NewGitHubOIDCProvider(registryURL)
|
||||
case "dns":
|
||||
log.Println("Using DNS-based authentication")
|
||||
authProvider = auth.NewDNSProvider(registryURL, dnsDomain, dnsPrivateKey)
|
||||
case "http":
|
||||
log.Println("Using HTTP-based authentication")
|
||||
authProvider = auth.NewHTTPProvider(registryURL, httpDomain, httpPrivateKey)
|
||||
case "none":
|
||||
log.Println("Using anonymous authentication")
|
||||
authProvider = auth.NewNoneProvider(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(®istryName, "registry", registryNPM, "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 packageTypeDocker:
|
||||
runtimeHint = packageTypeDocker
|
||||
case registryNPM:
|
||||
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
|
||||
var publishReq map[string]any
|
||||
if _, hasServerField := mcpDetails["server"]; hasServerField {
|
||||
// Already in PublishRequest format with server field (and possibly x-publisher)
|
||||
publishReq = mcpDetails
|
||||
} else {
|
||||
// Legacy ServerDetail format - wrap it in extension wrapper format
|
||||
publishReq = map[string]any{
|
||||
"server": 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
|
||||
}
|
||||
|
||||
// parseEnvironmentVariables parses environment variable specifications
|
||||
func parseEnvironmentVariables(envVars []string) []model.KeyValueInput {
|
||||
var environmentVariables []model.KeyValueInput
|
||||
for _, envVar := range envVars {
|
||||
parts := strings.SplitN(envVar, ":", 2)
|
||||
description := fmt.Sprintf("Environment variable for %s", parts[0])
|
||||
if len(parts) == 2 {
|
||||
description = parts[1]
|
||||
}
|
||||
|
||||
environmentVariables = append(environmentVariables, model.KeyValueInput{
|
||||
Name: parts[0],
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: description,
|
||||
Format: model.FormatString,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return environmentVariables
|
||||
}
|
||||
|
||||
// parsePackageArguments parses package argument specifications
|
||||
func parsePackageArguments(packageArgs []string) []model.Argument {
|
||||
var packageArguments []model.Argument
|
||||
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, model.Argument{
|
||||
Type: model.ArgumentTypePositional,
|
||||
ValueHint: value,
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: description,
|
||||
IsRequired: true, // Package arguments are typically required
|
||||
Format: model.FormatString,
|
||||
Value: value,
|
||||
Default: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return packageArguments
|
||||
}
|
||||
|
||||
// parseRuntimeArguments parses the execute command to create runtime arguments
|
||||
func parseRuntimeArguments(execute string) []model.Argument {
|
||||
var runtimeArguments []model.Argument
|
||||
if execute == "" {
|
||||
return runtimeArguments
|
||||
}
|
||||
|
||||
// Split the execute command into parts, handling quoted strings
|
||||
parts := smartSplit(execute)
|
||||
if len(parts) <= 1 {
|
||||
return runtimeArguments
|
||||
}
|
||||
|
||||
// Skip the first part (command) and add each argument as a runtime argument
|
||||
for i, arg := range parts[1:] {
|
||||
description := getArgumentDescription(arg, i, parts)
|
||||
runtimeArguments = append(runtimeArguments, model.Argument{
|
||||
Type: model.ArgumentTypePositional,
|
||||
ValueHint: arg,
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: description,
|
||||
IsRequired: false,
|
||||
Format: model.FormatString,
|
||||
Value: arg,
|
||||
Default: arg,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return runtimeArguments
|
||||
}
|
||||
|
||||
// getArgumentDescription provides a description for a runtime argument
|
||||
func getArgumentDescription(arg string, index int, parts []string) string {
|
||||
description := fmt.Sprintf("Runtime argument %d", index+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 index > 0 && strings.HasPrefix(parts[index], "-"):
|
||||
description = fmt.Sprintf("Value for %s", parts[index])
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
func createServerStructure(
|
||||
name, description, version, repoURL, repoSource, registryName,
|
||||
packageName, packageVersion, runtimeHint, execute string,
|
||||
envVars []string, packageArgs []string, status string,
|
||||
) model.ServerJSON {
|
||||
// Parse environment variables
|
||||
environmentVariables := parseEnvironmentVariables(envVars)
|
||||
|
||||
// Parse package arguments
|
||||
packageArguments := parsePackageArguments(packageArgs)
|
||||
|
||||
// Parse execute command to create runtime arguments
|
||||
runtimeArguments := parseRuntimeArguments(execute)
|
||||
|
||||
// Determine registry_type and registry_base_url from registryName
|
||||
var registryTypeValue, registryBaseURLValue, identifier string
|
||||
switch registryName {
|
||||
case registryNPM:
|
||||
registryTypeValue = "npm"
|
||||
registryBaseURLValue = "https://registry.npmjs.org"
|
||||
identifier = packageName
|
||||
case registryPyPI:
|
||||
registryTypeValue = "pypi"
|
||||
registryBaseURLValue = "https://pypi.org"
|
||||
identifier = packageName
|
||||
case registryDocker:
|
||||
registryTypeValue = "docker-hub"
|
||||
registryBaseURLValue = "https://docker.io"
|
||||
identifier = packageName
|
||||
case registryNuGet:
|
||||
registryTypeValue = "nuget"
|
||||
registryBaseURLValue = "https://api.nuget.org"
|
||||
identifier = packageName
|
||||
case registryURL:
|
||||
// For URL-based packages, determine registry from URL and file type
|
||||
if strings.HasSuffix(strings.ToLower(packageName), ".mcpb") {
|
||||
registryTypeValue = "mcpb"
|
||||
// Determine base URL from the download URL
|
||||
switch {
|
||||
case strings.Contains(packageName, "github.com"):
|
||||
registryBaseURLValue = "https://github.com"
|
||||
case strings.Contains(packageName, "gitlab.com"):
|
||||
registryBaseURLValue = "https://gitlab.com"
|
||||
default:
|
||||
registryBaseURLValue = ""
|
||||
}
|
||||
} else {
|
||||
registryTypeValue = "url"
|
||||
registryBaseURLValue = ""
|
||||
}
|
||||
identifier = packageName
|
||||
default:
|
||||
// Unknown or custom registry
|
||||
registryTypeValue = registryName
|
||||
registryBaseURLValue = ""
|
||||
identifier = packageName
|
||||
}
|
||||
|
||||
// Create package with new structured fields
|
||||
pkg := model.Package{
|
||||
RegistryType: registryTypeValue,
|
||||
RegistryBaseURL: registryBaseURLValue,
|
||||
Identifier: identifier,
|
||||
Version: packageVersion,
|
||||
RunTimeHint: runtimeHint,
|
||||
RuntimeArguments: runtimeArguments,
|
||||
PackageArguments: packageArguments,
|
||||
EnvironmentVariables: environmentVariables,
|
||||
}
|
||||
|
||||
// Convert string status to model.Status type
|
||||
var statusValue model.Status
|
||||
switch status {
|
||||
case "active":
|
||||
statusValue = model.StatusActive
|
||||
case "deprecated":
|
||||
statusValue = model.StatusDeprecated
|
||||
default:
|
||||
statusValue = model.StatusActive // fallback to active
|
||||
}
|
||||
|
||||
// Create server structure
|
||||
return model.ServerJSON{
|
||||
Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
||||
Name: name,
|
||||
Description: description,
|
||||
Status: statusValue,
|
||||
Repository: model.Repository{
|
||||
URL: repoURL,
|
||||
Source: repoSource,
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: version,
|
||||
},
|
||||
Packages: []model.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
|
||||
}
|
||||
_, _ = fmt.Fprintln(os.Stdout, "MCP Registry Publisher Tool")
|
||||
_, _ = fmt.Fprintln(os.Stdout)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher <command> [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)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Use 'mcp-publisher <command> --help' for more information about a command.")
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
||||
"description": "<your description here>",
|
||||
"name": "io.github.<owner>/<server-name>",
|
||||
"status": "active",
|
||||
"packages": [
|
||||
{
|
||||
"package_type": "javascript",
|
||||
"registry_name": "npm",
|
||||
"identifier": "io.github.<owner>/<server-name>",
|
||||
"version": "0.2.23",
|
||||
"package_arguments": [
|
||||
{
|
||||
"description": "Specify services and permissions.",
|
||||
"is_required": true,
|
||||
"format": "string",
|
||||
"value": "-s",
|
||||
"default": "-s",
|
||||
"type": "positional",
|
||||
"value_hint": "-s"
|
||||
}
|
||||
],
|
||||
"environment_variables": [
|
||||
{
|
||||
"description": "API Key to access the server",
|
||||
"name": "API_KEY"
|
||||
}
|
||||
]
|
||||
},{
|
||||
"package_type": "docker",
|
||||
"registry_name": "docker-hub",
|
||||
"identifier": "io.github.<owner>/<server-name>-cli:0.123.223",
|
||||
"version": "0.123.223",
|
||||
"runtime_hint": "docker",
|
||||
"runtime_arguments": [
|
||||
{
|
||||
"description": "Specify services and permissions.",
|
||||
"is_required": true,
|
||||
"format": "string",
|
||||
"value": "--mount",
|
||||
"default": "--mount",
|
||||
"type": "positional",
|
||||
"value_hint": "--mount"
|
||||
}
|
||||
],
|
||||
"environment_variables": [
|
||||
{
|
||||
"description": "API Key to access the server",
|
||||
"name": "API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"repository": {
|
||||
"url": "https://github.com/<owner>/<server-name>",
|
||||
"source": "github",
|
||||
"id": "c8d4c3b6-7943-474d-8e14-b53ab876a56a"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "0.0.1-<publisher_version>"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue