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
19 changed files with 11 additions and 11 deletions
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -18,10 +18,10 @@
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/tools/publisher/main.go",
|
"program": "${workspaceFolder}/cmd/publisher/main.go",
|
||||||
"args": [
|
"args": [
|
||||||
"-registry-url=http://localhost:8080",
|
"-registry-url=http://localhost:8080",
|
||||||
"-mcp-file=${workspaceFolder}/tools/publisher/server.json",
|
"-mcp-file=${workspaceFolder}/cmd/publisher/server.json",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -10,7 +10,7 @@ build: ## Build the registry application
|
||||||
go build -o bin/registry ./cmd/registry
|
go build -o bin/registry ./cmd/registry
|
||||||
|
|
||||||
publisher: ## Build the publisher tool
|
publisher: ## Build the publisher tool
|
||||||
cd tools/publisher && ./build.sh
|
cd cmd/publisher && ./build.sh
|
||||||
|
|
||||||
# Test targets
|
# Test targets
|
||||||
test-unit: ## Run unit tests with coverage
|
test-unit: ## Run unit tests with coverage
|
||||||
|
|
@ -64,7 +64,7 @@ dev-local: ## Run registry locally
|
||||||
clean: ## Clean build artifacts and coverage files
|
clean: ## Clean build artifacts and coverage files
|
||||||
rm -rf bin
|
rm -rf bin
|
||||||
rm -f coverage.out coverage.html
|
rm -f coverage.out coverage.html
|
||||||
cd tools/publisher && rm -f publisher
|
cd cmd/publisher && rm -f publisher
|
||||||
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,10 @@ To publish a server, we've built a simple CLI. You can use it with:
|
||||||
make publisher
|
make publisher
|
||||||
|
|
||||||
# Use it!
|
# Use it!
|
||||||
./tools/publisher/bin/mcp-publisher --help
|
./cmd/publisher/bin/mcp-publisher --help
|
||||||
```
|
```
|
||||||
|
|
||||||
See [the publisher README](./tools/publisher/README.md) for more details.
|
See [the publisher README](./cmd/publisher/README.md) for more details.
|
||||||
|
|
||||||
#### Other commands
|
#### Other commands
|
||||||
|
|
||||||
|
|
@ -127,6 +127,7 @@ For Claude and other AI tools: Always prefer make targets over custom commands w
|
||||||
|
|
||||||
```
|
```
|
||||||
├── cmd/ # Application entry points
|
├── cmd/ # Application entry points
|
||||||
|
│ └── publisher/ # Server publishing tool
|
||||||
├── data/ # Seed data
|
├── data/ # Seed data
|
||||||
├── deploy/ # Deployment configuration (Pulumi)
|
├── deploy/ # Deployment configuration (Pulumi)
|
||||||
├── docs/ # Technical documentation
|
├── docs/ # Technical documentation
|
||||||
|
|
@ -147,7 +148,6 @@ For Claude and other AI tools: Always prefer make targets over custom commands w
|
||||||
├── scripts/ # Development and testing scripts
|
├── scripts/ # Development and testing scripts
|
||||||
├── tests/ # Integration tests
|
├── tests/ # Integration tests
|
||||||
└── tools/ # CLI tools and utilities
|
└── tools/ # CLI tools and utilities
|
||||||
├── publisher/ # Server publishing tool
|
|
||||||
└── validate-*.sh # Schema validation tools
|
└── validate-*.sh # Schema validation tools
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
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.")
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ See [roadmap.md](./roadmap.md).
|
||||||
|
|
||||||
### How do I publish my MCP server?
|
### How do I publish my MCP server?
|
||||||
|
|
||||||
See the [publisher README](../tools/publisher/README.md)
|
See the [publisher README](../cmd/publisher/README.md)
|
||||||
|
|
||||||
### What namespaces are available?
|
### What namespaces are available?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ cleanup() {
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
}
|
}
|
||||||
|
|
||||||
go build -o ./bin/publisher ./tools/publisher
|
go build -o ./bin/publisher ./cmd/publisher
|
||||||
|
|
||||||
docker build -t registry .
|
docker build -t registry .
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue