feat: Add creds for GitHub auth, add "none" auth to publisher (#276)

## Motivation and Context

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

## How Has This Been Tested?

- Tested publisher workflows locally
- Unit and integration tests passing

## Breaking Changes

None

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

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

---

Merging based on Tadas's approval review, and only making minor docs changes on top of that.
This commit is contained in:
adam jones 2025-08-18 15:49:04 +01:00 • committed by GitHub
commit e9fe6f9ab8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 275 additions and 113 deletions

View file

@ -42,7 +42,7 @@ The tool supports two main commands:
- `-registry-url`: URL of the MCP registry (required)
- `-mcp-file`: Path to the MCP configuration file (required)
- `-login`: Force a new GitHub authentication even if a token already exists (overwrites existing token file)
- `-auth-method`: Authentication method to use (default: github-oauth)
- `-auth-method`: Authentication method to use (default: github)
## Creating a server.json file
@ -132,8 +132,9 @@ The tool has been simplified to use **GitHub OAuth device flow authentication ex
1. **Automatic Setup**: The tool automatically retrieves the GitHub Client ID from the registry's health endpoint
2. **First Run Authentication**: When first run (or with the `--login` flag), the tool initiates the GitHub device flow
3. **User Authorization**: You'll be provided with a URL and a verification code to enter on GitHub
4. **Token Storage**: After successful authentication, the tool saves the access token locally in `.mcpregistry_token` for future use
5. **Secure Communication**: The token is sent in the HTTP Authorization header with the Bearer scheme for all registry API calls
4. **Token Storage**: After successful authentication, the tool saves the access token locally in `.mcpregistry_github_token` for future use
5. **Token Exchange**: The GitHub token is exchanged for a short-lived registry token, which is saved locally in `.mcpregistry_registry_token`.
5. **Secure Communication**: The registry token is sent in the HTTP Authorization header with the Bearer scheme for all registry API calls
**Note**: Authentication is performed via GitHub OAuth App, which you must authorize for the respective resources (e.g., organization access if publishing organization repositories).
@ -141,46 +142,7 @@ The tool has been simplified to use **GitHub OAuth device flow authentication ex
To publish an existing server.json file to the registry:
1. Prepare your `server.json` file with your server details:
```json
{
"name": "io.github.yourusername/your-repository",
"description": "Your MCP server description",
"version_detail": {
"version": "1.0.0"
},
"packages": [
{
"registry_name": "npm",
"name": "your-npm-package",
"version": "1.0.0",
"package_arguments": [
{
"description": "Specify services and permissions",
"is_required": true,
"format": "string",
"value": "-s",
"default": "-s",
"type": "positional",
"value_hint": "-s"
}
],
"environment_variables": [
{
"name": "API_KEY",
"description": "API Key to access the server"
}
]
}
],
"repository": {
"url": "https://github.com/yourusername/your-repository",
"source": "github"
}
}
```
1. Prepare your `server.json` file with your server details. See an example in [server.json](./server.json)
2. Run the publisher tool:
```bash
@ -190,12 +152,3 @@ To publish an existing server.json file to the registry:
3. Follow the authentication instructions in the terminal if prompted.
4. Upon successful publication, you'll see a confirmation message.
## Important Notes
- **GitHub Authentication Only**: The tool exclusively uses GitHub OAuth device flow for authentication
- **Automatic Client ID**: The GitHub Client ID is automatically retrieved from the registry's health endpoint
- **Token Storage**: The authentication token is saved in `.mcpregistry_token` in the current directory
- **Internet Required**: Active internet connection needed for GitHub authentication and registry communication
- **Repository Access**: Ensure the repository and package mentioned in your `server.json` file exist and are accessible
- **OAuth Permissions**: You may need to grant the OAuth app access to your GitHub organizations if publishing org repositories

View file

@ -79,7 +79,7 @@ The main application automatically selects the appropriate authentication provid
// Create the appropriate auth provider based on configuration
var authProvider auth.Provider
switch authMethod {
case "github-oauth":
case "github":
log.Println("Using GitHub OAuth for authentication")
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
default:

View file

@ -13,7 +13,8 @@ import (
)
const (
tokenFilePath = ".mcpregistry_token" // #nosec:G101
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
@ -36,6 +37,18 @@ type AccessTokenResponse struct {
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"`
}
// OAuthProvider implements the AuthProvider interface using GitHub's device flow
type OAuthProvider struct {
clientID string
@ -57,9 +70,33 @@ func NewOAuthProvider(forceLogin bool, registryURL string) *OAuthProvider {
}
}
// GetToken retrieves the GitHub access token
func (g *OAuthProvider) GetToken(_ context.Context) (string, error) {
return readToken()
// GetToken retrieves the registry JWT token (exchanges GitHub token if needed)
func (g *OAuthProvider) 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
@ -68,8 +105,21 @@ func (g *OAuthProvider) NeedsLogin() bool {
return true
}
_, statErr := os.Stat(tokenFilePath)
return os.IsNotExist(statErr)
// 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
@ -115,7 +165,7 @@ func (g *OAuthProvider) Login(ctx context.Context) error {
// Name returns the name of this auth provider
func (g *OAuthProvider) Name() string {
return "github-oauth"
return "github"
}
// requestDeviceCode initiates the device authorization flow
@ -237,12 +287,12 @@ func (g *OAuthProvider) pollForToken(ctx context.Context, deviceCode string) (st
// saveToken saves the GitHub access token to a local file
func saveToken(token string) error {
return os.WriteFile(tokenFilePath, []byte(token), 0600)
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(tokenFilePath)
tokenData, err := os.ReadFile(gitHubTokenFilePath)
if err != nil {
return "", err
}
@ -292,3 +342,91 @@ func getClientID(ctx context.Context, registryURL string) (string, error) {
return githubClientID, nil
}
// exchangeTokenForRegistry exchanges a GitHub token for a registry JWT token
func (g *OAuthProvider) 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"
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
}

View file

@ -0,0 +1,78 @@
package none
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
)
type Provider struct {
registryURL string
token string
}
type TokenResponse struct {
RegistryToken string `json:"registry_token"`
ExpiresAt int64 `json:"expires_at"`
}
func NewProvider(registryURL string) auth.Provider { //nolint:ireturn
return &Provider{
registryURL: registryURL,
}
}
func (p *Provider) 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 *Provider) NeedsLogin() bool {
return false
}
func (p *Provider) Login(_ context.Context) error {
// No login needed for anonymous auth
return nil
}
func (p *Provider) Name() string {
return "none"
}

View file

@ -15,6 +15,7 @@ import (
"github.com/modelcontextprotocol/registry/tools/publisher/auth"
"github.com/modelcontextprotocol/registry/tools/publisher/auth/github"
"github.com/modelcontextprotocol/registry/tools/publisher/auth/none"
)
// Server structure types for JSON generation
@ -103,8 +104,8 @@ func publishCommand() error {
publishFlags.StringVar(&registryURL, "registry-url", "", "URL of the registry (required)")
publishFlags.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)")
publishFlags.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists")
publishFlags.StringVar(&authMethod, "auth-method", "github-oauth",
"authentication method to use (default: github-oauth)")
publishFlags.StringVar(&authMethod, "auth-method", "github",
"authentication method (github for authenticated, none for anonymous) (default: github)")
// Set custom usage function
publishFlags.Usage = func() {
@ -116,7 +117,7 @@ func publishCommand() error {
fmt.Fprint(os.Stdout, " --registry-url string URL of the registry (required)\n")
fmt.Fprint(os.Stdout, " --mcp-file string path to the MCP file (required)\n")
fmt.Fprint(os.Stdout, " --login force a new login even if a token exists\n")
fmt.Fprint(os.Stdout, " --auth-method string authentication method to use (default: github-oauth)\n")
fmt.Fprint(os.Stdout, " --auth-method string authentication method (github for authenticated, none for anonymous) (default: github)\n")
}
if err := publishFlags.Parse(os.Args[2:]); err != nil {
@ -136,9 +137,12 @@ func publishCommand() error {
var authProvider auth.Provider // Determine the authentication method
switch authMethod {
case "github-oauth":
case "github":
log.Println("Using GitHub OAuth for authentication")
authProvider = github.NewOAuthProvider(forceLogin, registryURL)
case "none":
log.Println("Using anonymous authentication")
authProvider = none.NewProvider(registryURL)
default:
return fmt.Errorf("unsupported authentication method: %s", authMethod)
}

View file

@ -50,7 +50,8 @@
],
"repository": {
"url": "https://github.com/<owner>/<server-name>",
"source": "github"
"source": "github",
"id": "c8d4c3b6-7943-474d-8e14-b53ab876a56a"
},
"version_detail": {
"version": "0.0.1-<publisher_version>"