feat(auth): Implement GitHub OAuth authentication flow for package publishing
- Added authentication service and GitHub OAuth integration. - Introduced new authentication methods and structures in the model. - Updated the API to handle authentication during the publishing process. - Created handlers for starting and checking the status of the OAuth flow. - Enhanced the publish handler to validate authentication credentials. - Updated configuration to include GitHub OAuth settings. - Added tests for the OAuth flow and publishing with authentication. - Updated documentation to reflect changes in the API and authentication process.
This commit is contained in:
parent
582d93a103
commit
2089c5aaa1
18 changed files with 924 additions and 35 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
build/*
|
build/*
|
||||||
data
|
data
|
||||||
|
.env
|
||||||
|
|
|
||||||
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Launch server",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}/cmd/registry",
|
||||||
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Launch publisher",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}/tools/publisher/main.go",
|
||||||
|
"args": [
|
||||||
|
"-registry-url=http://localhost:8080",
|
||||||
|
"-mcp-file=${workspaceFolder}/tools/publisher/mcp.json",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
Makefile
8
Makefile
|
|
@ -79,10 +79,10 @@ lint:
|
||||||
build-all: clean
|
build-all: clean
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_PATH)
|
@GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_PACKAGE)
|
||||||
@GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_PATH)
|
@GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_PACKAGE)
|
||||||
@GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_PATH)
|
@GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_PACKAGE)
|
||||||
@GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_PATH)
|
@GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_PACKAGE)
|
||||||
@echo "Cross-compilation complete!"
|
@echo "Cross-compilation complete!"
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/registry/internal/api"
|
"github.com/modelcontextprotocol/registry/internal/api"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
"github.com/modelcontextprotocol/registry/internal/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
"github.com/modelcontextprotocol/registry/internal/database"
|
"github.com/modelcontextprotocol/registry/internal/database"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
|
|
@ -70,8 +71,23 @@ func main() {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize authentication services
|
||||||
|
authService := auth.NewAuthService(cfg)
|
||||||
|
|
||||||
|
if cfg.RequireAuth {
|
||||||
|
log.Println("Authentication is required for package publishing")
|
||||||
|
|
||||||
|
if cfg.GithubClientID == "" || cfg.GithubClientSecret == "" {
|
||||||
|
log.Println("Warning: GitHub OAuth credentials not configured but authentication is required")
|
||||||
|
} else {
|
||||||
|
log.Println("GitHub OAuth authentication is configured")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Println("Authentication is optional for package publishing")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
server := api.NewServer(cfg, registryService)
|
server := api.NewServer(cfg, registryService, authService)
|
||||||
|
|
||||||
// Start server in a goroutine so it doesn't block signal handling
|
// Start server in a goroutine so it doesn't block signal handling
|
||||||
go func() {
|
go func() {
|
||||||
|
|
|
||||||
117
internal/api/handlers/v0/auth.go
Normal file
117
internal/api/handlers/v0/auth.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Package v0 contains API handlers for version 0 of the API
|
||||||
|
package v0
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartAuthHandler handles requests to start an authentication flow
|
||||||
|
func StartAuthHandler(authService auth.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only allow POST method
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error reading request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse request body into AuthRequest struct
|
||||||
|
var authReq struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
RepoRef string `json:"repo_ref"`
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(body, &authReq)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if authReq.Method == "" {
|
||||||
|
http.Error(w, "Auth method is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert string method to enum type
|
||||||
|
var method model.AuthMethod
|
||||||
|
switch authReq.Method {
|
||||||
|
case "github":
|
||||||
|
method = model.AuthMethodGitHub
|
||||||
|
default:
|
||||||
|
http.Error(w, "Unsupported authentication method", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start auth flow
|
||||||
|
flowInfo, statusToken, err := authService.StartAuthFlow(r.Context(), method, authReq.RepoRef)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to start auth flow: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return successful response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"flow_info": flowInfo,
|
||||||
|
"status_token": statusToken,
|
||||||
|
"expires_in": 300, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAuthStatusHandler handles requests to check the status of an authentication flow
|
||||||
|
func CheckAuthStatusHandler(authService auth.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only allow GET method
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status token from query parameter
|
||||||
|
statusToken := r.URL.Query().Get("token")
|
||||||
|
if statusToken == "" {
|
||||||
|
http.Error(w, "Status token is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth status
|
||||||
|
token, err := authService.CheckAuthStatus(r.Context(), statusToken)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "pending" {
|
||||||
|
// Auth is still pending
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "pending",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other error
|
||||||
|
http.Error(w, "Failed to check auth status: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication completed successfully
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "complete",
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,24 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
"github.com/modelcontextprotocol/registry/internal/model"
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// httpError represents an HTTP error with a message and status code
|
||||||
|
type httpError struct {
|
||||||
|
msg string
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the error message
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
|
|
||||||
// PublishHandler handles requests to publish new server details to the registry
|
// PublishHandler handles requests to publish new server details to the registry
|
||||||
func PublishHandler(registry service.RegistryService) http.HandlerFunc {
|
func PublishHandler(registry service.RegistryService, authService auth.Service) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Only allow POST method
|
// Only allow POST method
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
@ -27,26 +39,48 @@ func PublishHandler(registry service.RegistryService) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
// Parse request body into ServerDetail struct
|
// Parse request body into PublishRequest struct
|
||||||
var serverDetail model.ServerDetail
|
var publishReq model.PublishRequest
|
||||||
err = json.Unmarshal(body, &serverDetail)
|
err = json.Unmarshal(body, &publishReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid request payload", http.StatusBadRequest)
|
http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get server details from the request
|
||||||
|
serverDetail := publishReq.ServerDetail
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if serverDetail.Name == "" {
|
if serverDetail.Name == "" {
|
||||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// version is required
|
// Version is required
|
||||||
if serverDetail.VersionDetail.Version == "" {
|
if serverDetail.VersionDetail.Version == "" {
|
||||||
http.Error(w, "Version is required", http.StatusBadRequest)
|
http.Error(w, "Version is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate authentication credentials
|
||||||
|
if authService != nil {
|
||||||
|
publishReq.Authentication.RepoRef = serverDetail.Name
|
||||||
|
valid, err := authService.ValidateAuth(r.Context(), publishReq.Authentication)
|
||||||
|
if err != nil {
|
||||||
|
if err == auth.ErrAuthRequired {
|
||||||
|
http.Error(w, "Authentication is required for publishing", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
http.Error(w, "Invalid authentication credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Call the publish method on the registry service
|
// Call the publish method on the registry service
|
||||||
err = registry.Publish(&serverDetail)
|
err = registry.Publish(&serverDetail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,17 @@ package router
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
"github.com/modelcontextprotocol/registry/internal/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New creates a new router with all API versions registered
|
// New creates a new router with all API versions registered
|
||||||
func New(cfg *config.Config, registry service.RegistryService) *http.ServeMux {
|
func New(cfg *config.Config, registry service.RegistryService, authService auth.Service) *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Register routes for all API versions
|
// Register routes for all API versions
|
||||||
RegisterV0Routes(mux, cfg, registry)
|
RegisterV0Routes(mux, cfg, registry, authService)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,19 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
"github.com/modelcontextprotocol/registry/internal/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterV0Routes registers all v0 API routes to the provided router
|
// RegisterV0Routes registers all v0 API routes to the provided router
|
||||||
func RegisterV0Routes(mux *http.ServeMux, cfg *config.Config, registry service.RegistryService) {
|
func RegisterV0Routes(mux *http.ServeMux, cfg *config.Config, registry service.RegistryService, authService auth.Service) {
|
||||||
// Register v0 endpoints
|
// Register v0 endpoints
|
||||||
mux.HandleFunc("/v0/health", v0.HealthHandler())
|
mux.HandleFunc("/v0/health", v0.HealthHandler())
|
||||||
mux.HandleFunc("/v0/servers", v0.ServersHandler(registry))
|
mux.HandleFunc("/v0/servers", v0.ServersHandler(registry))
|
||||||
mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry))
|
mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry))
|
||||||
mux.HandleFunc("/v0/ping", v0.PingHandler(cfg))
|
mux.HandleFunc("/v0/ping", v0.PingHandler(cfg))
|
||||||
mux.HandleFunc("/v0/publish", v0.PublishHandler(registry))
|
mux.HandleFunc("/v0/publish", v0.PublishHandler(registry, authService))
|
||||||
|
|
||||||
// Register Swagger UI routes
|
// Register Swagger UI routes
|
||||||
mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler())
|
mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler())
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,30 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/registry/internal/api/router"
|
"github.com/modelcontextprotocol/registry/internal/api/router"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||||
"github.com/modelcontextprotocol/registry/internal/config"
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
"github.com/modelcontextprotocol/registry/internal/service"
|
"github.com/modelcontextprotocol/registry/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server represents the HTTP server
|
// Server represents the HTTP server
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
registry service.RegistryService
|
registry service.RegistryService
|
||||||
router *http.ServeMux
|
authService auth.Service
|
||||||
server *http.Server
|
router *http.ServeMux
|
||||||
|
server *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new HTTP server
|
// NewServer creates a new HTTP server
|
||||||
func NewServer(cfg *config.Config, registryService service.RegistryService) *Server {
|
func NewServer(cfg *config.Config, registryService service.RegistryService, authService auth.Service) *Server {
|
||||||
// Create router with all API versions registered
|
// Create router with all API versions registered
|
||||||
mux := router.New(cfg, registryService)
|
mux := router.New(cfg, registryService, authService)
|
||||||
|
|
||||||
server := &Server{
|
server := &Server{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
registry: registryService,
|
registry: registryService,
|
||||||
router: mux,
|
authService: authService,
|
||||||
|
router: mux,
|
||||||
server: &http.Server{
|
server: &http.Server{
|
||||||
Addr: cfg.ServerAddress,
|
Addr: cfg.ServerAddress,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
|
|
||||||
28
internal/auth/auth.go
Normal file
28
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Package auth provides authentication mechanisms for the MCP registry
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAuthRequired is returned when authentication is required but not provided
|
||||||
|
ErrAuthRequired = errors.New("authentication required")
|
||||||
|
// ErrUnsupportedAuthMethod is returned when an unsupported auth method is used
|
||||||
|
ErrUnsupportedAuthMethod = errors.New("unsupported authentication method")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service defines the authentication service interface
|
||||||
|
type Service interface {
|
||||||
|
// StartAuthFlow initiates an authentication flow and returns the flow information
|
||||||
|
StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error)
|
||||||
|
|
||||||
|
// CheckAuthStatus checks the status of an authentication flow using a status token
|
||||||
|
CheckAuthStatus(ctx context.Context, statusToken string) (string, error)
|
||||||
|
|
||||||
|
// ValidateAuth validates the authentication credentials
|
||||||
|
ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error)
|
||||||
|
}
|
||||||
158
internal/auth/github.go
Normal file
158
internal/auth/github.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
// Package auth provides authentication mechanisms for the MCP registry
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GitHubValidateTokenURL = "https://api.github.com/applications/CLIENT_ID/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAuthFailed is returned when authentication fails
|
||||||
|
ErrAuthFailed = errors.New("authentication failed")
|
||||||
|
// ErrInvalidToken is returned when a token is invalid
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
// ErrMissingScope is returned when a token doesn't have the required scope
|
||||||
|
ErrMissingScope = errors.New("token missing required scope")
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHubOAuthConfig holds the configuration for GitHub OAuth
|
||||||
|
type GitHubOAuthConfig struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenValidationResponse represents the response from GitHub's token validation endpoint
|
||||||
|
type TokenValidationResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
SingleFile string `json:"single_file,omitempty"`
|
||||||
|
Repository string `json:"repository,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHubDeviceAuth provides methods for GitHub device OAuth authentication
|
||||||
|
type GitHubDeviceAuth struct {
|
||||||
|
config GitHubOAuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitHubDeviceAuth creates a new GitHub device auth instance
|
||||||
|
func NewGitHubDeviceAuth(config GitHubOAuthConfig) *GitHubDeviceAuth {
|
||||||
|
return &GitHubDeviceAuth{
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken checks if a token has write:packages access to the required repository
|
||||||
|
func (g *GitHubDeviceAuth) ValidateToken(token string, requiredRepo string) (bool, error) {
|
||||||
|
// If no repo is required, we can't validate properly
|
||||||
|
if requiredRepo == "" {
|
||||||
|
return false, fmt.Errorf("repository reference is required for token validation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if the token is valid using the app token check endpoint
|
||||||
|
req, err := http.NewRequest("POST", GitHubValidateTokenURL, bytes.NewBuffer([]byte(`{"access_token": "`+token+`"}`)))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.URL.Path = fmt.Sprintf("/applications/%s/token", g.config.ClientID)
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.SetBasicAuth(g.config.ClientID, g.config.ClientSecret)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("token validation failed: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationResp TokenValidationResponse
|
||||||
|
err = json.Unmarshal(body, &validationResp)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now check if the token has permissions to the specific repository
|
||||||
|
// Use the GitHub API to check repository-specific permissions
|
||||||
|
repoCheckURL := fmt.Sprintf("https://api.github.com/repos/%s", requiredRepo)
|
||||||
|
repoReq, err := http.NewRequest("GET", repoCheckURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repoReq.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
repoReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
|
||||||
|
repoResp, err := client.Do(repoReq)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer repoResp.Body.Close()
|
||||||
|
|
||||||
|
// Read and check response
|
||||||
|
if repoResp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("token does not have access to repository: %s (status: %d)", requiredRepo, repoResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've reached this point, the token has access the repo
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GitHubDeviceAuth) ExtractGitHubRepoFromName(n string) (owner, repo string, err error) {
|
||||||
|
// match io.github.<owner>/<repo>
|
||||||
|
regexp := regexp.MustCompile(`io\.github\.([^/]+)/([^/]+)`)
|
||||||
|
matches := regexp.FindStringSubmatch(n)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
return "", "", fmt.Errorf("invalid GitHub repository name: %s", n)
|
||||||
|
}
|
||||||
|
return matches[1], matches[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGitHubRepo extracts the owner and repository name from a GitHub repository URL
|
||||||
|
func (g *GitHubDeviceAuth) ExtractGitHubRepo(repoURL string) (owner, repo string, err error) {
|
||||||
|
regexp := regexp.MustCompile(`github\.com/([^/]+)/([^/]+)`)
|
||||||
|
matches := regexp.FindStringSubmatch(repoURL)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
return "", "", fmt.Errorf("invalid GitHub repository URL: %s", repoURL)
|
||||||
|
}
|
||||||
|
return matches[1], matches[2], nil
|
||||||
|
}
|
||||||
67
internal/auth/service.go
Normal file
67
internal/auth/service.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/config"
|
||||||
|
"github.com/modelcontextprotocol/registry/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthServiceImpl implements the Service interface
|
||||||
|
type AuthServiceImpl struct {
|
||||||
|
config *config.Config
|
||||||
|
githubAuth *GitHubDeviceAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService creates a new authentication service
|
||||||
|
func NewAuthService(cfg *config.Config) Service {
|
||||||
|
githubConfig := GitHubOAuthConfig{
|
||||||
|
ClientID: cfg.GithubClientID,
|
||||||
|
ClientSecret: cfg.GithubClientSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthServiceImpl{
|
||||||
|
config: cfg,
|
||||||
|
githubAuth: NewGitHubDeviceAuth(githubConfig),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthServiceImpl) StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error) {
|
||||||
|
// return not implemented error
|
||||||
|
return nil, "", fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthServiceImpl) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) {
|
||||||
|
// return not implemented error
|
||||||
|
return "", fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAuth validates authentication credentials
|
||||||
|
func (s *AuthServiceImpl) ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error) {
|
||||||
|
// If authentication is not required, allow access
|
||||||
|
if !s.config.RequireAuth {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If authentication is required but not provided
|
||||||
|
if auth.Method == "" || auth.Method == model.AuthMethodNone {
|
||||||
|
return false, ErrAuthRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
switch auth.Method {
|
||||||
|
case model.AuthMethodGitHub:
|
||||||
|
// Extract repo reference from the repository URL if it's not provided
|
||||||
|
owner, repo, err := s.githubAuth.ExtractGitHubRepoFromName(auth.RepoRef)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
repoRef := fmt.Sprintf("%s/%s", owner, repo)
|
||||||
|
if repoRef == "" {
|
||||||
|
return false, fmt.Errorf("repository reference is required for GitHub authentication")
|
||||||
|
}
|
||||||
|
return s.githubAuth.ValidateToken(auth.Token, repoRef)
|
||||||
|
default:
|
||||||
|
return false, ErrUnsupportedAuthMethod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,11 @@ type Config struct {
|
||||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||||
Environment string `env:"ENVIRONMENT" envDefault:"production"`
|
Environment string `env:"ENVIRONMENT" envDefault:"production"`
|
||||||
Version string `env:"VERSION" envDefault:"dev"`
|
Version string `env:"VERSION" envDefault:"dev"`
|
||||||
|
|
||||||
|
// GitHub OAuth settings
|
||||||
|
GithubClientID string `env:"GITHUB_CLIENT_ID" envDefault:""`
|
||||||
|
GithubClientSecret string `env:"GITHUB_CLIENT_SECRET" envDefault:""`
|
||||||
|
RequireAuth bool `env:"REQUIRE_AUTH" envDefault:"false"` // Whether to require authentication for publishing
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig creates a new configuration with default values
|
// NewConfig creates a new configuration with default values
|
||||||
|
|
|
||||||
|
|
@ -192,15 +192,6 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail
|
||||||
return fmt.Errorf("version must be greater than existing version")
|
return fmt.Errorf("version must be greater than existing version")
|
||||||
}
|
}
|
||||||
|
|
||||||
// update the existing entry to not be the latest version
|
|
||||||
if existingEntry.ID != "" {
|
|
||||||
_, err = db.collection.UpdateOne(ctx, bson.M{"id": existingEntry.ID}, bson.M{"$set": bson.M{"versiondetail.islatest": false}})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error updating existing entry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
serverDetail.ID = uuid.New().String()
|
serverDetail.ID = uuid.New().String()
|
||||||
serverDetail.VersionDetail.IsLatest = true
|
serverDetail.VersionDetail.IsLatest = true
|
||||||
serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
|
serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
|
||||||
|
|
@ -214,6 +205,15 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail
|
||||||
return fmt.Errorf("error inserting entry: %w", err)
|
return fmt.Errorf("error inserting entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update the existing entry to not be the latest version
|
||||||
|
if existingEntry.ID != "" {
|
||||||
|
_, err = db.collection.UpdateOne(ctx, bson.M{"id": existingEntry.ID}, bson.M{"$set": bson.M{"versiondetail.islatest": false}})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error updating existing entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
65
internal/docs/publish_swagger.yaml
Normal file
65
internal/docs/publish_swagger.yaml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/v0/publish:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- servers
|
||||||
|
summary: Publish a server to the registry
|
||||||
|
description: Adds a new server to the MCP registry with authentication
|
||||||
|
operationId: publishServer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
server_detail:
|
||||||
|
$ref: '#/components/schemas/ServerDetail'
|
||||||
|
authentication:
|
||||||
|
$ref: '#/components/schemas/Authentication'
|
||||||
|
auth_status_token:
|
||||||
|
type: string
|
||||||
|
description: Optional status token from a device authorization flow
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Server published successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Server publication successful
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
example: 1234567890abcdef12345678
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: Name is required
|
||||||
|
'401':
|
||||||
|
description: Authentication failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: Authentication is required for publishing
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: Failed to publish server details
|
||||||
|
|
@ -1,5 +1,29 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
|
// AuthMethod represents the authentication method used
|
||||||
|
type AuthMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AuthMethodGitHub represents GitHub OAuth authentication
|
||||||
|
AuthMethodGitHub AuthMethod = "github"
|
||||||
|
// AuthMethodNone represents no authentication
|
||||||
|
AuthMethodNone AuthMethod = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authentication holds information about the authentication method and credentials
|
||||||
|
type Authentication struct {
|
||||||
|
Method AuthMethod `json:"method,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
RepoRef string `json:"repo_ref,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishRequest represents a request to publish a server to the registry
|
||||||
|
type PublishRequest struct {
|
||||||
|
ServerDetail ServerDetail `json:"server_detail"`
|
||||||
|
Authentication Authentication `json:"authentication,omitempty"`
|
||||||
|
AuthStatusToken string `json:"-"` // Used internally for device flows
|
||||||
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
|
@ -25,8 +49,8 @@ type ServerDetail struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
VersionDetail VersionDetail `json:"version_detail,omitempty"`
|
VersionDetail VersionDetail `json:"version_detail,omitempty"`
|
||||||
Repository Repository `json:"repository,omitempty"`
|
Repository Repository `json:"-"` // Not included in the JSON response
|
||||||
RegistryCanonical string `json:"registry_canonical,omitempty"`
|
RegistryCanonical string `json:"-"` // Not included in the JSON response
|
||||||
Registries []Registries `json:"registries,omitempty"`
|
Registries []Registries `json:"registries,omitempty"`
|
||||||
Remotes []Remotes `json:"remotes,omitempty"`
|
Remotes []Remotes `json:"remotes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
303
tools/publisher/main.go
Normal file
303
tools/publisher/main.go
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenFilePath = ".mcpregistry_token"
|
||||||
|
GithubClientID = "Iv23ctIMuVtIi2bxoi9o"
|
||||||
|
|
||||||
|
// GitHub OAuth URLs
|
||||||
|
GitHubDeviceCodeURL = "https://github.com/login/device/code"
|
||||||
|
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var registryURL string
|
||||||
|
var mcpFilePath string
|
||||||
|
var forceLogin bool
|
||||||
|
|
||||||
|
flag.StringVar(®istryURL, "registry-url", "", "URL of the registry(required)")
|
||||||
|
flag.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file(required)")
|
||||||
|
flag.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if registryURL == "" || mcpFilePath == "" {
|
||||||
|
flag.Usage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token exists or force login is requested
|
||||||
|
_, statErr := os.Stat(tokenFilePath)
|
||||||
|
if forceLogin || os.IsNotExist(statErr) {
|
||||||
|
err := performDeviceFlowLogin()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to perform device flow login: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the token
|
||||||
|
token, err := readToken()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading token: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read MCP file
|
||||||
|
mcpData, err := os.ReadFile(mcpFilePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading MCP file: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to registry
|
||||||
|
err = publishToRegistry(registryURL, mcpData, token)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to publish to registry: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Successfully published to registry!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func performDeviceFlowLogin() error {
|
||||||
|
// Device flow login logic using GitHub's device flow
|
||||||
|
// First, request a device code
|
||||||
|
deviceCode, userCode, verificationURI, err := requestDeviceCode()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error requesting device code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display instructions to the user
|
||||||
|
fmt.Println("\nTo authenticate, please:")
|
||||||
|
fmt.Println("1. Go to:", verificationURI)
|
||||||
|
fmt.Println("2. Enter code:", userCode)
|
||||||
|
fmt.Println("3. Authorize this application")
|
||||||
|
|
||||||
|
// Poll for the token
|
||||||
|
fmt.Println("Waiting for authorization...")
|
||||||
|
token, err := pollForToken(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.Println("Successfully authenticated!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestDeviceCode initiates the device authorization flow
|
||||||
|
func requestDeviceCode() (string, string, string, error) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"client_id": GithubClientID,
|
||||||
|
"scope": "write:packages repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", 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 pollForToken(deviceCode string) (string, error) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"client_id": GithubClientID,
|
||||||
|
"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.NewRequest("POST", 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
|
||||||
|
fmt.Print(".")
|
||||||
|
time.Sleep(time.Duration(interval) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.Error != "" {
|
||||||
|
return "", fmt.Errorf("token request failed: %s", tokenResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.AccessToken != "" {
|
||||||
|
fmt.Println() // Add newline after dots
|
||||||
|
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(tokenFilePath, []byte(token), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readToken reads the GitHub access token from a local file
|
||||||
|
func readToken() (string, error) {
|
||||||
|
tokenData, err := os.ReadFile(tokenFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(tokenData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishToRegistry sends the MCP server details to the registry with authentication
|
||||||
|
func publishToRegistry(registryURL string, mcpData []byte, token string) error {
|
||||||
|
// Parse the MCP JSON data
|
||||||
|
var mcpDetails map[string]interface{}
|
||||||
|
err := json.Unmarshal(mcpData, &mcpDetails)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing mcp.json file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the publish request payload
|
||||||
|
publishReq := map[string]interface{}{
|
||||||
|
"server_detail": mcpDetails,
|
||||||
|
"authentication": map[string]interface{}{
|
||||||
|
"method": "github",
|
||||||
|
"token": token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the request to JSON
|
||||||
|
jsonData, err := json.Marshal(publishReq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error serializing request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the URL ends with the publish endpoint
|
||||||
|
if !strings.HasSuffix(registryURL, "/") {
|
||||||
|
registryURL += "/"
|
||||||
|
}
|
||||||
|
publishURL := registryURL + "v0/publish"
|
||||||
|
|
||||||
|
// Create and send the request
|
||||||
|
req, err := http.NewRequest("POST", publishURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error sending request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read and check the response
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("publication failed with status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
println(string(body))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
38
tools/publisher/mcp.json
Normal file
38
tools/publisher/mcp.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "io.github.<username>/<repository>",
|
||||||
|
"description": "<description>",
|
||||||
|
"version_detail": {
|
||||||
|
"version": "1.0.2"
|
||||||
|
},
|
||||||
|
"registries": [
|
||||||
|
{
|
||||||
|
"name": "npm",
|
||||||
|
"package_name": "<npm_package_name>",
|
||||||
|
"license": "MIT",
|
||||||
|
"command_arguments": {
|
||||||
|
"sub_commands": [
|
||||||
|
{
|
||||||
|
"name": "start",
|
||||||
|
"description": "Start the server",
|
||||||
|
"named_arguments": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"positional_arguments": null,
|
||||||
|
"environment_variables": [
|
||||||
|
{
|
||||||
|
"name": "PORT",
|
||||||
|
"description": "Port to run the server on",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"named_arguments": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remotes": [
|
||||||
|
{
|
||||||
|
"transporttype": "http",
|
||||||
|
"url": "http://example.com/api"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue