diff --git a/.gitignore b/.gitignore index 9b8d11e..676571c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/* data +.env diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d16ff05 --- /dev/null +++ b/.vscode/launch.json @@ -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", + ], + } + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile index 6c2e69e..3441d3c 100644 --- a/Makefile +++ b/Makefile @@ -79,10 +79,10 @@ lint: build-all: clean @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - @GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_PATH) - @GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_PATH) - @GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_PATH) - @GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(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_PACKAGE) + @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_PACKAGE) @echo "Cross-compilation complete!" # Show help diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 94f0957..53ba0d2 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/modelcontextprotocol/registry/internal/api" + "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/database" "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 - server := api.NewServer(cfg, registryService) + server := api.NewServer(cfg, registryService, authService) // Start server in a goroutine so it doesn't block signal handling go func() { diff --git a/internal/api/handlers/v0/auth.go b/internal/api/handlers/v0/auth.go new file mode 100644 index 0000000..bc3f7b3 --- /dev/null +++ b/internal/api/handlers/v0/auth.go @@ -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, + }) + } +} diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index 8bfa70b..3565d1f 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -6,12 +6,24 @@ import ( "io" "net/http" + "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/model" "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 -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) { // Only allow POST method if r.Method != http.MethodPost { @@ -27,26 +39,48 @@ func PublishHandler(registry service.RegistryService) http.HandlerFunc { } defer r.Body.Close() - // Parse request body into ServerDetail struct - var serverDetail model.ServerDetail - err = json.Unmarshal(body, &serverDetail) + // Parse request body into PublishRequest struct + var publishReq model.PublishRequest + err = json.Unmarshal(body, &publishReq) if err != nil { - http.Error(w, "Invalid request payload", http.StatusBadRequest) + http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest) return } + // Get server details from the request + serverDetail := publishReq.ServerDetail + // Validate required fields if serverDetail.Name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } - // version is required + // Version is required if serverDetail.VersionDetail.Version == "" { http.Error(w, "Version is required", http.StatusBadRequest) 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 err = registry.Publish(&serverDetail) if err != nil { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 78a0c4b..9f17880 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -4,16 +4,17 @@ package router import ( "net/http" + "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/service" ) // 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() // Register routes for all API versions - RegisterV0Routes(mux, cfg, registry) + RegisterV0Routes(mux, cfg, registry, authService) return mux } diff --git a/internal/api/router/v0.go b/internal/api/router/v0.go index 3099c15..839dd09 100644 --- a/internal/api/router/v0.go +++ b/internal/api/router/v0.go @@ -5,18 +5,19 @@ import ( "net/http" 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/service" ) // 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 mux.HandleFunc("/v0/health", v0.HealthHandler()) mux.HandleFunc("/v0/servers", v0.ServersHandler(registry)) mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry)) 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 mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler()) diff --git a/internal/api/server.go b/internal/api/server.go index 293ca1f..5371582 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -6,27 +6,30 @@ import ( "net/http" "github.com/modelcontextprotocol/registry/internal/api/router" + "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/service" ) // Server represents the HTTP server type Server struct { - config *config.Config - registry service.RegistryService - router *http.ServeMux - server *http.Server + config *config.Config + registry service.RegistryService + authService auth.Service + router *http.ServeMux + server *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 - mux := router.New(cfg, registryService) + mux := router.New(cfg, registryService, authService) server := &Server{ - config: cfg, - registry: registryService, - router: mux, + config: cfg, + registry: registryService, + authService: authService, + router: mux, server: &http.Server{ Addr: cfg.ServerAddress, Handler: mux, diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..43c8331 --- /dev/null +++ b/internal/auth/auth.go @@ -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) +} diff --git a/internal/auth/github.go b/internal/auth/github.go new file mode 100644 index 0000000..964e093 --- /dev/null +++ b/internal/auth/github.go @@ -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./ + 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 +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..445ea37 --- /dev/null +++ b/internal/auth/service.go @@ -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 + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ee86984..a6139f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,11 @@ type Config struct { LogLevel string `env:"LOG_LEVEL" envDefault:"info"` Environment string `env:"ENVIRONMENT" envDefault:"production"` 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 diff --git a/internal/database/mongo.go b/internal/database/mongo.go index ab9e8c3..18cbeee 100644 --- a/internal/database/mongo.go +++ b/internal/database/mongo.go @@ -192,15 +192,6 @@ func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail 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.VersionDetail.IsLatest = true 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) } + // 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 } diff --git a/internal/docs/publish_swagger.yaml b/internal/docs/publish_swagger.yaml new file mode 100644 index 0000000..7a49a4d --- /dev/null +++ b/internal/docs/publish_swagger.yaml @@ -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 diff --git a/internal/model/model.go b/internal/model/model.go index c95f9ad..8ace909 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,5 +1,29 @@ 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 { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -25,8 +49,8 @@ type ServerDetail struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` VersionDetail VersionDetail `json:"version_detail,omitempty"` - Repository Repository `json:"repository,omitempty"` - RegistryCanonical string `json:"registry_canonical,omitempty"` + Repository Repository `json:"-"` // Not included in the JSON response + RegistryCanonical string `json:"-"` // Not included in the JSON response Registries []Registries `json:"registries,omitempty"` Remotes []Remotes `json:"remotes,omitempty"` } diff --git a/tools/publisher/main.go b/tools/publisher/main.go new file mode 100644 index 0000000..b3faaaa --- /dev/null +++ b/tools/publisher/main.go @@ -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 +} diff --git a/tools/publisher/mcp.json b/tools/publisher/mcp.json new file mode 100644 index 0000000..c80c8aa --- /dev/null +++ b/tools/publisher/mcp.json @@ -0,0 +1,38 @@ +{ + "name": "io.github./", + "description": "", + "version_detail": { + "version": "1.0.2" + }, + "registries": [ + { + "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" + } + ] +} \ No newline at end of file