Move publisher CLI from tools/ to cmd/ (#326)
## Summary Move the publisher CLI from `tools/publisher/` to `cmd/publisher/` as it's a proper application rather than a development utility. This addresses feedback from @rdimitrov in PR #324 that the publisher CLI should be under `cmd/` since it's not really a tool in the same sense as other helpers in the `tools/` folder. ## Changes - Moved `tools/publisher/` directory to `cmd/publisher/` - Updated Makefile build and clean targets - Updated README.md documentation links - Updated VS Code launch configuration - Updated integration test script - Updated FAQ documentation - Fixed Go import paths in publisher code --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
parent
b8106340bc
commit
53f4d2fd8e
14 changed files with 11 additions and 11 deletions
218
cmd/publisher/README.md
Normal file
218
cmd/publisher/README.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# MCP Registry Publisher Tool
|
||||
|
||||
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.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
./build.sh
|
||||
|
||||
# Build for all supported platforms (optional)
|
||||
./build.sh --all
|
||||
```
|
||||
|
||||
The compiled binary will be placed in the `bin` directory.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Create a template server.json file
|
||||
mcp-publisher init
|
||||
|
||||
# 2. Edit server.json with your server details
|
||||
|
||||
# 3. Authenticate with the registry (one-time setup)
|
||||
mcp-publisher login <method>
|
||||
|
||||
# 4. Publish your server
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `mcp-publisher init`
|
||||
|
||||
Creates a template `server.json` file with smart defaults. This command automatically detects values from your environment when possible.
|
||||
|
||||
```bash
|
||||
mcp-publisher init
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The generated file includes placeholder values that you should update before publishing.
|
||||
|
||||
### `mcp-publisher login <method>`
|
||||
|
||||
Authenticates with the MCP registry. You'll only need to do this once - the tool saves your credentials locally.
|
||||
|
||||
```bash
|
||||
# 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...
|
||||
```
|
||||
|
||||
**Authentication Methods:**
|
||||
|
||||
- **`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`.
|
||||
|
||||
### `mcp-publisher publish`
|
||||
|
||||
Publishes your `server.json` file to the registry. You must be logged in first.
|
||||
|
||||
```bash
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
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
|
||||
mcp-publisher logout
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
For automated publishing from GitHub Actions:
|
||||
|
||||
```yaml
|
||||
name: Publish to MCP Registry
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup MCP Publisher
|
||||
run: |
|
||||
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
|
||||
```
|
||||
|
||||
The `login github-oidc` command uses GitHub's OIDC tokens for authentication in CI environments.
|
||||
|
||||
## Examples
|
||||
|
||||
### 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 "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 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 the TXT record to your domain's DNS
|
||||
|
||||
3. Login with:
|
||||
```bash
|
||||
mcp-publisher login dns --domain example.com --private-key YOUR_PRIVATE_KEY
|
||||
```
|
||||
|
||||
This grants publishing rights for `example.com/*` and `*.example.com/*` namespaces.
|
||||
|
||||
### HTTP Authentication
|
||||
|
||||
For HTTP-based authentication:
|
||||
|
||||
1. Generate an Ed25519 keypair (same as DNS)
|
||||
|
||||
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`.
|
||||
121
cmd/publisher/auth/common.go
Normal file
121
cmd/publisher/auth/common.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CryptoProvider provides common functionality for DNS and HTTP authentication
|
||||
type CryptoProvider struct {
|
||||
registryURL string
|
||||
domain string
|
||||
hexSeed string
|
||||
authMethod string
|
||||
}
|
||||
|
||||
// GetToken retrieves the registry JWT token using cryptographic authentication
|
||||
func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
|
||||
if c.domain == "" {
|
||||
return "", fmt.Errorf("%s domain is required", c.authMethod)
|
||||
}
|
||||
|
||||
if c.hexSeed == "" {
|
||||
return "", fmt.Errorf("%s private key (hex seed) is required", c.authMethod)
|
||||
}
|
||||
|
||||
// Decode hex seed to private key
|
||||
seedBytes, err := hex.DecodeString(c.hexSeed)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid hex seed format: %w", err)
|
||||
}
|
||||
|
||||
if len(seedBytes) != ed25519.SeedSize {
|
||||
return "", fmt.Errorf("invalid seed length: expected %d bytes, got %d", ed25519.SeedSize, len(seedBytes))
|
||||
}
|
||||
|
||||
privateKey := ed25519.NewKeyFromSeed(seedBytes)
|
||||
|
||||
// Generate current timestamp
|
||||
timestamp := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Sign the timestamp
|
||||
signature := ed25519.Sign(privateKey, []byte(timestamp))
|
||||
signedTimestamp := hex.EncodeToString(signature)
|
||||
|
||||
// Exchange signature for registry token
|
||||
registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, timestamp, signedTimestamp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to exchange %s signature: %w", c.authMethod, err)
|
||||
}
|
||||
|
||||
return registryToken, nil
|
||||
}
|
||||
|
||||
// NeedsLogin always returns false for cryptographic auth since no interactive login is needed
|
||||
func (c *CryptoProvider) NeedsLogin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Login is not needed for cryptographic auth since authentication is cryptographic
|
||||
func (c *CryptoProvider) Login(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeTokenForRegistry exchanges signature for a registry JWT token
|
||||
func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, timestamp, signedTimestamp string) (string, error) {
|
||||
if c.registryURL == "" {
|
||||
return "", fmt.Errorf("registry URL is required for token exchange")
|
||||
}
|
||||
|
||||
// Prepare the request body
|
||||
payload := map[string]string{
|
||||
"domain": domain,
|
||||
"timestamp": timestamp,
|
||||
"signed_timestamp": signedTimestamp,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Make the token exchange request
|
||||
exchangeURL := fmt.Sprintf("%s/v0/auth/%s", c.registryURL, c.authMethod)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var tokenResp RegistryTokenResponse
|
||||
err = json.Unmarshal(body, &tokenResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return tokenResp.RegistryToken, nil
|
||||
}
|
||||
22
cmd/publisher/auth/dns.go
Normal file
22
cmd/publisher/auth/dns.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package auth
|
||||
|
||||
type DNSProvider struct {
|
||||
*CryptoProvider
|
||||
}
|
||||
|
||||
// NewDNSProvider creates a new DNS-based auth provider
|
||||
func NewDNSProvider(registryURL, domain, hexSeed string) Provider {
|
||||
return &DNSProvider{
|
||||
CryptoProvider: &CryptoProvider{
|
||||
registryURL: registryURL,
|
||||
domain: domain,
|
||||
hexSeed: hexSeed,
|
||||
authMethod: "dns",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name of this auth provider
|
||||
func (d *DNSProvider) Name() string {
|
||||
return "dns"
|
||||
}
|
||||
426
cmd/publisher/auth/github-at.go
Normal file
426
cmd/publisher/auth/github-at.go
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gitHubTokenFilePath = ".mcpregistry_github_token" // #nosec:G101
|
||||
registryTokenFilePath = ".mcpregistry_registry_token" // #nosec:G101
|
||||
// GitHub OAuth URLs
|
||||
GitHubDeviceCodeURL = "https://github.com/login/device/code" // #nosec:G101
|
||||
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101
|
||||
)
|
||||
|
||||
// DeviceCodeResponse represents the response from GitHub's device code endpoint
|
||||
type DeviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// AccessTokenResponse represents the response from GitHub's access token endpoint
|
||||
type AccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegistryTokenResponse represents the response from registry's token exchange endpoint
|
||||
type RegistryTokenResponse struct {
|
||||
RegistryToken string `json:"registry_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// StoredRegistryToken represents the registry token with expiration stored locally
|
||||
type StoredRegistryToken struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// GitHubATProvider implements the Provider interface using GitHub's device flow
|
||||
type GitHubATProvider struct {
|
||||
clientID string
|
||||
forceLogin bool
|
||||
registryURL string
|
||||
}
|
||||
|
||||
// ServerHealthResponse represents the response from the health endpoint
|
||||
type ServerHealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
GitHubClientID string `json:"github_client_id"`
|
||||
}
|
||||
|
||||
// NewGitHubATProvider creates a new GitHub OAuth provider
|
||||
func NewGitHubATProvider(forceLogin bool, registryURL string) Provider {
|
||||
return &GitHubATProvider{
|
||||
forceLogin: forceLogin,
|
||||
registryURL: registryURL,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves the registry JWT token (exchanges GitHub token if needed)
|
||||
func (g *GitHubATProvider) GetToken(ctx context.Context) (string, error) {
|
||||
// Check if we have a valid registry token
|
||||
registryToken, err := readRegistryToken()
|
||||
if err == nil && registryToken != "" {
|
||||
return registryToken, nil
|
||||
}
|
||||
|
||||
// If no valid registry token, exchange GitHub token for registry token
|
||||
githubToken, err := readToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read GitHub token: %w", err)
|
||||
}
|
||||
|
||||
// Exchange GitHub token for registry token
|
||||
registryToken, expiresAt, err := g.exchangeTokenForRegistry(ctx, githubToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to exchange token: %w", err)
|
||||
}
|
||||
|
||||
// Store the registry token
|
||||
err = saveRegistryToken(registryToken, expiresAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to save registry token: %w", err)
|
||||
}
|
||||
|
||||
return registryToken, nil
|
||||
}
|
||||
|
||||
// NeedsLogin checks if a new login is required
|
||||
func (g *GitHubATProvider) NeedsLogin() bool {
|
||||
if g.forceLogin {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if GitHub token exists
|
||||
_, statErr := os.Stat(gitHubTokenFilePath)
|
||||
if os.IsNotExist(statErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if valid registry token exists
|
||||
_, err := readRegistryToken()
|
||||
if err != nil {
|
||||
// No valid registry token, but we have GitHub token
|
||||
// We don't need to login, just exchange tokens
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Login performs the GitHub device flow authentication
|
||||
func (g *GitHubATProvider) Login(ctx context.Context) error {
|
||||
// If clientID is not set, try to retrieve it from the server's health endpoint
|
||||
if g.clientID == "" {
|
||||
clientID, err := getClientID(ctx, g.registryURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting GitHub Client ID: %w", err)
|
||||
}
|
||||
g.clientID = clientID
|
||||
}
|
||||
|
||||
// Device flow login logic using GitHub's device flow
|
||||
// First, request a device code
|
||||
deviceCode, userCode, verificationURI, err := g.requestDeviceCode(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error requesting device code: %w", err)
|
||||
}
|
||||
|
||||
// Display instructions to the user
|
||||
_, _ = 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
|
||||
_, _ = 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)
|
||||
}
|
||||
|
||||
// Store the token locally
|
||||
err = saveToken(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving token: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Successfully authenticated!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name returns the name of this auth provider
|
||||
func (g *GitHubATProvider) Name() string {
|
||||
return "github"
|
||||
}
|
||||
|
||||
// requestDeviceCode initiates the device authorization flow
|
||||
func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) {
|
||||
if g.clientID == "" {
|
||||
return "", "", "", fmt.Errorf("GitHub Client ID is required for device flow login")
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"client_id": g.clientID,
|
||||
"scope": "read:org read:user",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubDeviceCodeURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", "", fmt.Errorf("request device code failed: %s", body)
|
||||
}
|
||||
|
||||
var deviceCodeResp DeviceCodeResponse
|
||||
err = json.Unmarshal(body, &deviceCodeResp)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil
|
||||
}
|
||||
|
||||
// pollForToken polls for access token after user completes authorization
|
||||
func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) {
|
||||
if g.clientID == "" {
|
||||
return "", fmt.Errorf("GitHub Client ID is required for device flow login")
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"client_id": g.clientID,
|
||||
"device_code": deviceCode,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Default polling interval and expiration time
|
||||
interval := 5 // seconds
|
||||
expiresIn := 900 // 15 minutes
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var tokenResp AccessTokenResponse
|
||||
err = json.Unmarshal(body, &tokenResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if tokenResp.Error == "authorization_pending" {
|
||||
// User hasn't authorized yet, wait and retry
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if tokenResp.Error != "" {
|
||||
return "", fmt.Errorf("token request failed: %s", tokenResp.Error)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken != "" {
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// If we reach here, something unexpected happened
|
||||
return "", fmt.Errorf("failed to obtain access token")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("device code authorization timed out")
|
||||
}
|
||||
|
||||
// saveToken saves the GitHub access token to a local file
|
||||
func saveToken(token string) error {
|
||||
return os.WriteFile(gitHubTokenFilePath, []byte(token), 0600)
|
||||
}
|
||||
|
||||
// readToken reads the GitHub access token from a local file
|
||||
func readToken() (string, error) {
|
||||
tokenData, err := os.ReadFile(gitHubTokenFilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(tokenData), nil
|
||||
}
|
||||
|
||||
func getClientID(ctx context.Context, registryURL string) (string, error) {
|
||||
// This function should retrieve the GitHub Client ID from the registry URL
|
||||
// For now, we will return a placeholder value
|
||||
// In a real implementation, this would likely involve querying the registry or configuration
|
||||
if registryURL == "" {
|
||||
return "", fmt.Errorf("registry URL is required to get GitHub Client ID")
|
||||
}
|
||||
// get the clientID from the server's health endpoint
|
||||
healthURL := registryURL + "/v0/health"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.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 {
|
||||
return "", err
|
||||
}
|
||||
if healthResponse.GitHubClientID == "" {
|
||||
return "", fmt.Errorf("GitHub Client ID is not set in the server's health response")
|
||||
}
|
||||
|
||||
githubClientID := healthResponse.GitHubClientID
|
||||
|
||||
return githubClientID, nil
|
||||
}
|
||||
|
||||
// exchangeTokenForRegistry exchanges a GitHub token for a registry JWT token
|
||||
func (g *GitHubATProvider) exchangeTokenForRegistry(ctx context.Context, githubToken string) (string, int64, error) {
|
||||
if g.registryURL == "" {
|
||||
return "", 0, fmt.Errorf("registry URL is required for token exchange")
|
||||
}
|
||||
|
||||
// Prepare the request body
|
||||
payload := map[string]string{
|
||||
"github_token": githubToken,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Make the token exchange request
|
||||
exchangeURL := g.registryURL + "/v0/auth/github-at"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var tokenResp RegistryTokenResponse
|
||||
err = json.Unmarshal(body, &tokenResp)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return tokenResp.RegistryToken, tokenResp.ExpiresAt, nil
|
||||
}
|
||||
|
||||
// saveRegistryToken saves the registry JWT token to a local file with expiration
|
||||
func saveRegistryToken(token string, expiresAt int64) error {
|
||||
storedToken := StoredRegistryToken{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(storedToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal token: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(registryTokenFilePath, data, 0600)
|
||||
}
|
||||
|
||||
// readRegistryToken reads the registry JWT token from a local file
|
||||
func readRegistryToken() (string, error) {
|
||||
data, err := os.ReadFile(registryTokenFilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var storedToken StoredRegistryToken
|
||||
err = json.Unmarshal(data, &storedToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal token: %w", err)
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if time.Now().Unix() >= storedToken.ExpiresAt {
|
||||
// Token has expired, remove the file
|
||||
os.Remove(registryTokenFilePath)
|
||||
return "", fmt.Errorf("registry token has expired")
|
||||
}
|
||||
|
||||
return storedToken.Token, nil
|
||||
}
|
||||
166
cmd/publisher/auth/github-oidc.go
Normal file
166
cmd/publisher/auth/github-oidc.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
type GitHubOIDCProvider struct {
|
||||
registryURL string
|
||||
}
|
||||
|
||||
// NewGitHubOIDCProvider creates a new GitHub OIDC provider
|
||||
func NewGitHubOIDCProvider(registryURL string) Provider {
|
||||
return &GitHubOIDCProvider{
|
||||
registryURL: registryURL,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves the registry JWT token using GitHub Actions OIDC token
|
||||
func (o *GitHubOIDCProvider) GetToken(ctx context.Context) (string, error) {
|
||||
// Get OIDC token from GitHub Actions endpoint
|
||||
oidcToken, err := o.getOIDCTokenFromGitHub(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get OIDC token from GitHub: %w", err)
|
||||
}
|
||||
|
||||
// Exchange OIDC token for registry token
|
||||
registryToken, err := o.exchangeOIDCTokenForRegistry(ctx, oidcToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to exchange OIDC token: %w", err)
|
||||
}
|
||||
|
||||
return registryToken, nil
|
||||
}
|
||||
|
||||
// NeedsLogin always returns false for OIDC since the token is provided by GitHub Actions
|
||||
func (o *GitHubOIDCProvider) NeedsLogin() bool {
|
||||
// OIDC tokens are provided by GitHub Actions runtime, no interactive login needed
|
||||
return false
|
||||
}
|
||||
|
||||
// Login is not needed for OIDC since tokens are provided by GitHub Actions
|
||||
func (o *GitHubOIDCProvider) Login(_ context.Context) error {
|
||||
// No interactive login needed for OIDC
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name returns the name of this auth provider
|
||||
func (o *GitHubOIDCProvider) Name() string {
|
||||
return "github-oidc"
|
||||
}
|
||||
|
||||
// exchangeOIDCTokenForRegistry exchanges a GitHub OIDC token for a registry JWT token
|
||||
func (o *GitHubOIDCProvider) exchangeOIDCTokenForRegistry(ctx context.Context, oidcToken string) (string, error) {
|
||||
if o.registryURL == "" {
|
||||
return "", fmt.Errorf("registry URL is required for token exchange")
|
||||
}
|
||||
|
||||
// Prepare the request body
|
||||
payload := map[string]string{
|
||||
"oidc_token": oidcToken,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Make the token exchange request
|
||||
exchangeURL := o.registryURL + "/v0/auth/github-oidc"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var tokenResp RegistryTokenResponse
|
||||
err = json.Unmarshal(body, &tokenResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return tokenResp.RegistryToken, nil
|
||||
}
|
||||
|
||||
// getOIDCTokenFromGitHub fetches the OIDC token from GitHub Actions endpoint
|
||||
func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context) (string, error) {
|
||||
// Check for required environment variables
|
||||
requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
|
||||
if requestToken == "" {
|
||||
return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable not found - are you running in GitHub Actions with id-token: write permissions?")
|
||||
}
|
||||
|
||||
requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
|
||||
if requestURL == "" {
|
||||
return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_URL environment variable not found - are you running in GitHub Actions with id-token: write permissions?")
|
||||
}
|
||||
|
||||
// Build the full URL with audience parameter
|
||||
fullURL := requestURL + "&audience=mcp-registry"
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set the authorization header
|
||||
req.Header.Set("Authorization", "Bearer "+requestToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// Make the request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GitHub OIDC token request failed with status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Parse the response to extract the token value
|
||||
var tokenResp struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
err = json.Unmarshal(body, &tokenResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal OIDC token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Value == "" {
|
||||
return "", fmt.Errorf("OIDC token value is empty in response")
|
||||
}
|
||||
|
||||
return tokenResp.Value, nil
|
||||
}
|
||||
22
cmd/publisher/auth/http.go
Normal file
22
cmd/publisher/auth/http.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package auth
|
||||
|
||||
type HTTPProvider struct {
|
||||
*CryptoProvider
|
||||
}
|
||||
|
||||
// NewHTTPProvider creates a new HTTP-based auth provider
|
||||
func NewHTTPProvider(registryURL, domain, hexSeed string) Provider {
|
||||
return &HTTPProvider{
|
||||
CryptoProvider: &CryptoProvider{
|
||||
registryURL: registryURL,
|
||||
domain: domain,
|
||||
hexSeed: hexSeed,
|
||||
authMethod: "http",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name of this auth provider
|
||||
func (h *HTTPProvider) Name() string {
|
||||
return "http"
|
||||
}
|
||||
21
cmd/publisher/auth/interface.go
Normal file
21
cmd/publisher/auth/interface.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider defines the interface for authentication mechanisms
|
||||
type Provider interface {
|
||||
// GetToken retrieves or generates an authentication token
|
||||
// It returns the token string and any error encountered
|
||||
GetToken(ctx context.Context) (string, error)
|
||||
|
||||
// NeedsLogin checks if a new login is required
|
||||
// This can check for existing tokens, expiry, etc.
|
||||
NeedsLogin() bool
|
||||
|
||||
// Login performs the authentication flow
|
||||
// This might involve user interaction, device flows, etc.
|
||||
Login(ctx context.Context) error
|
||||
|
||||
// Name returns the name of the authentication provider
|
||||
Name() string
|
||||
}
|
||||
76
cmd/publisher/auth/none.go
Normal file
76
cmd/publisher/auth/none.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type NoneProvider struct {
|
||||
registryURL string
|
||||
token string
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
RegistryToken string `json:"registry_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func NewNoneProvider(registryURL string) Provider {
|
||||
return &NoneProvider{
|
||||
registryURL: registryURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NoneProvider) GetToken(ctx context.Context) (string, error) {
|
||||
if p.token != "" {
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
// Get anonymous token from registry
|
||||
if !strings.HasSuffix(p.registryURL, "/") {
|
||||
p.registryURL += "/"
|
||||
}
|
||||
tokenURL := p.registryURL + "v0/auth/none"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting anonymous token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("failed to get anonymous token (status %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("error decoding token response: %w", err)
|
||||
}
|
||||
|
||||
p.token = tokenResp.RegistryToken
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
func (p *NoneProvider) NeedsLogin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *NoneProvider) Login(_ context.Context) error {
|
||||
// No login needed for anonymous auth
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *NoneProvider) Name() string {
|
||||
return "none"
|
||||
}
|
||||
41
cmd/publisher/build.sh
Executable file
41
cmd/publisher/build.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Build script for MCP Registry Publisher Tool
|
||||
|
||||
# Set variables
|
||||
OUTPUT_DIR="./bin"
|
||||
BINARY_NAME="mcp-publisher"
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
mkdir -p $OUTPUT_DIR
|
||||
|
||||
# Print build information
|
||||
echo "Building MCP Registry Publisher Tool..."
|
||||
|
||||
# Build for current platform
|
||||
echo "Building for $(go env GOOS)/$(go env GOARCH)..."
|
||||
go build -o "$OUTPUT_DIR/$BINARY_NAME" .
|
||||
|
||||
# Make the binary executable
|
||||
chmod +x "$OUTPUT_DIR/$BINARY_NAME"
|
||||
|
||||
echo "Build complete: $(realpath $OUTPUT_DIR/$BINARY_NAME)"
|
||||
|
||||
# Optional: Build for multiple platforms
|
||||
if [ "$1" == "--all" ]; then
|
||||
echo "Building for all supported platforms..."
|
||||
|
||||
# Linux AMD64
|
||||
GOOS=linux GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-linux-amd64" .
|
||||
|
||||
# MacOS AMD64
|
||||
GOOS=darwin GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-darwin-amd64" .
|
||||
|
||||
# MacOS ARM64 (Apple Silicon)
|
||||
GOOS=darwin GOARCH=arm64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-darwin-arm64" .
|
||||
|
||||
# Windows AMD64
|
||||
GOOS=windows GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-windows-amd64.exe" .
|
||||
|
||||
echo "Multi-platform build complete in $(realpath $OUTPUT_DIR)"
|
||||
fi
|
||||
319
cmd/publisher/commands/init.go
Normal file
319
cmd/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
cmd/publisher/commands/login.go
Normal file
105
cmd/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/cmd/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
cmd/publisher/commands/logout.go
Normal file
43
cmd/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
cmd/publisher/commands/publish.go
Normal file
132
cmd/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
|
||||
}
|
||||
53
cmd/publisher/main.go
Normal file
53
cmd/publisher/main.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
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 = 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 {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
_, _ = fmt.Fprintln(os.Stdout, "MCP Registry Publisher Tool")
|
||||
_, _ = fmt.Fprintln(os.Stdout)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
|
||||
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher <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.")
|
||||
}
|
||||
Loading…
Reference in a new issue