Init world. Basic registry API server

Work in progress .
This commit adds the core registry service implementation with methods to:
- Retrieve all registry entries
- List entries with cursor-based pagination
- Fetch specific server details by ID
This commit is contained in:
Avinash Sridhar 2025-04-22 17:09:44 -04:00
commit 4fb5bc477e
No known key found for this signature in database
24 changed files with 1702 additions and 2 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/*

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /build/registry ./cmd/registry
FROM alpine:latest
WORKDIR /app
COPY --from=builder /build/registry .
EXPOSE 8080
CMD ["./registry"]

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Model Context Protocol
Copyright (c) 2025 GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

102
Makefile Normal file
View file

@ -0,0 +1,102 @@
# Makefile for MCP Registry project
# Go parameters
BINARY_NAME=registry
MAIN_PACKAGE=./cmd/registry
BUILD_DIR=build
GO=go
# Build settings
VERSION=0.1.0
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS=-ldflags "-s -w -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)"
VERSION_INFO=Version: $(BINARY_NAME) v$(VERSION)
.PHONY: all build clean test run run-test lint fmt vet help
all: clean build
# Build the application
build:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
@$(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PACKAGE)
@echo "Build complete!"
# Clean build artifacts
clean:
@echo "Cleaning..."
@rm -rf $(BUILD_DIR)
@echo "Clean complete!"
# Run tests
test:
@echo "Running tests..."
@$(GO) test ./... -v
@echo "Tests complete!"
# Run the application
run:
@echo "Running $(BINARY_NAME)..."
@$(GO) run $(MAIN_PATH)
# Run the application with test environment (using fake service)
run-test:
@echo "Running $(BINARY_NAME) with test environment..."
@APP_ENV=test $(GO) run $(MAIN_PATH)
# Install dependencies
deps:
@echo "Installing dependencies..."
@$(GO) mod download
@echo "Dependencies installed!"
# Format code
fmt:
@echo "Formatting code..."
@$(GO) fmt ./...
@echo "Formatting complete!"
# Run go vet
vet:
@echo "Running go vet..."
@$(GO) vet ./...
@echo "Vet complete!"
# Run golint if installed
lint:
@if command -v golint > /dev/null; then \
echo "Running golint..."; \
golint ./...; \
echo "Lint complete!"; \
else \
echo "golint not installed. Run: go install golang.org/x/lint/golint@latest"; \
fi
# Build for multiple platforms (cross-compilation)
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)
@echo "Cross-compilation complete!"
# Show help
help:
@echo "Available targets:"
@echo " all - Clean and build the project"
@echo " build - Build the project binary"
@echo " clean - Remove build artifacts"
@echo " test - Run all tests"
@echo " run - Run the application"
@echo " run-test - Run the application with test environment (fake service)"
@echo " deps - Download dependencies"
@echo " fmt - Format code using go fmt"
@echo " vet - Run go vet"
@echo " lint - Run golint (if installed)"
@echo " build-all - Build for multiple platforms"
@echo " help - Show this help message"

206
README.md
View file

@ -1 +1,205 @@
# registry
# MCP Registry
A reference implementation of a registry service for Model Context Protocol (MCP) servers.
> **Note:** This project is currently a work in progress.
## Overview
The MCP Registry service provides a centralized repository for MCP server entries. It allows discovery and management of various MCP implementations with their associated metadata, configurations, and capabilities.
## Features
- RESTful API for managing MCP registry entries (list, get, create, update, delete)
- Health check endpoint for service monitoring
- Support for various environment configurations
- Graceful shutdown handling
- MongoDB and in-memory database support
- Comprehensive API documentation
- Pagination support for listing registry entries
## Project Structure
```
├── cmd/ # Application entry points
├── config/ # Configuration files
├── internal/ # Private application code
│ ├── api/ # HTTP server and request handlers
│ ├── config/ # Configuration management
│ ├── model/ # Data models
│ └── service/ # Business logic
├── pkg/ # Public libraries
├── scripts/ # Utility scripts
└── build/ # Build artifacts
```
## Getting Started
### Prerequisites
- Go 1.18 or later
- MongoDB (optional, for production use)
- Docker (optional, for containerized deployment)
### Building
```bash
make build
```
This will create the `mcp-registry` binary in the `build/` directory.
### Running
```bash
./build/mcp-registry
```
Alternatively, run
```bash
make run
```
By default, the service will run on `http://localhost:8080`.
### Docker Deployment
You can build and run the service using Docker:
```bash
# Build the Docker image
docker build -t mcp-registry .
# Run the container
docker run -p 8080:8080 mcp-registry
```
This will build and start the MCP Registry service in a container, exposing it on port 8080.
### Using the Fake Service
For development and testing purposes, the application includes a fake service with pre-populated registry entries. To use the fake service:
1. Set the environment to "test":
```bash
export APP_ENV=test
./build/mcp-registry
```
Alternatively, run
```bash
make run-test
```
The fake service provides three sample MCP registry entries with the following capabilities:
- Registry 1: Code generation and completion capabilities
- Registry 2: Chat and knowledge base capabilities
- Registry 3: Data visualization and analysis capabilities
You can interact with the fake data through the API endpoints:
- List all entries: `GET /servers`
The fake service is useful for:
- Frontend development without a real backend
- Testing API integrations
- Example data structure reference
## API Endpoints
### Health Check
```
GET /v0/health
```
Returns the health status of the service:
```json
{
"status": "ok"
}
```
### Registry Endpoints
#### List Registry Server Entries
```
GET /v0/servers
```
Lists MCP registry server entries with pagination support.
Query parameters:
- `limit`: Maximum number of entries to return (default: 30, max: 100)
- `cursor`: Pagination cursor for retrieving next set of results
Response example:
```json
{
"servers": [
{
"id": "1",
"name": "Example MCP Server",
"description": "An example MCP server implementation",
"url": "https://example.com/mcp",
"repository": {
"url": "https://github.com/example/mcp-server",
"stars": 120
},
"version": "1.0.0",
],
"metadata": {
"next_cursor": "cursor-value-for-next-page"
}
}
```
### Ping Endpoint
```
GET /v0/ping
```
Simple ping endpoint that returns environment configuration information:
```json
{
"environment": "dev",
"version": "mcp-registry-<sha>"
}
```
## Configuration
The service can be configured using environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `APP_ENV` | Application environment (production, test) | production |
| `APP_VERSION`| Application version | dev |
| `DATABASE_URL` | MongoDB connection string | mongodb://localhost:27017 |
## Testing
Run the test script to validate API endpoints:
```bash
./scripts/test_endpoints.sh
```
You can specify specific endpoints to test:
```bash
./scripts/test_endpoints.sh --endpoint health
./scripts/test_endpoints.sh --endpoint servers
```
## License
See the [LICENSE](LICENSE) file for details.

101
cmd/registry/main.go Normal file
View file

@ -0,0 +1,101 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/modelcontextprotocol/registry/internal/api"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/service"
)
func main() {
// Parse command line flags
showVersion := flag.Bool("version", false, "Display version information")
flag.Parse()
// Show version information if requested
if *showVersion {
fmt.Printf("MCP Registry v%s\n", Version)
fmt.Printf("Git commit: %s\n", GitCommit)
fmt.Printf("Build time: %s\n", BuildTime)
return
}
log.Printf("Starting MCP Registry Application v%s (commit: %s)", Version, GitCommit)
// Initialize configuration
cfg := config.NewConfig()
// Initialize services based on environment
var registryService service.RegistryService
if cfg.Environment == "test" {
// Use fake registry service in test environment
registryService = service.NewFakeRegistryService()
log.Println("Using fake registry service with dummy data (test environment)")
} else {
// Use MongoDB for real registry service in production/other environments
// Create a context with timeout for MongoDB connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Connect to MongoDB
mongoDB, err := database.NewMongoDB(ctx, cfg.DatabaseURL, cfg.DatabaseName, cfg.CollectionName)
if err != nil {
log.Fatalf("Failed to connect to MongoDB: %v", err)
}
// Create registry service with MongoDB
registryService = service.NewRegistryServiceWithDB(mongoDB)
log.Printf("Using MongoDB-based registry service (%s environment)", cfg.Environment)
log.Printf("MongoDB database name: %s", cfg.DatabaseName)
log.Printf("MongoDB collection name: %s", cfg.CollectionName)
// Store the MongoDB instance for later cleanup
defer func() {
if err := mongoDB.Close(); err != nil {
log.Printf("Error closing MongoDB connection: %v", err)
} else {
log.Println("MongoDB connection closed successfully")
}
}()
}
// Initialize HTTP server
server := api.NewServer(cfg, registryService)
// Start server in a goroutine so it doesn't block signal handling
go func() {
log.Printf("HTTP server listening on %s", cfg.ServerAddress)
if err := server.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Create context with timeout for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Gracefully shutdown the server
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exiting")
}

13
cmd/registry/version.go Normal file
View file

@ -0,0 +1,13 @@
package main
// Version info for the MCP Registry application
var (
// Version is the current version of the MCP Registry application
Version = "0.1.0"
// BuildTime is the time at which the binary was built
BuildTime = "undefined"
// GitCommit is the git commit that was compiled
GitCommit = "undefined"
)

21
go.mod Normal file
View file

@ -0,0 +1,21 @@
module github.com/modelcontextprotocol/registry
go 1.23.0
require (
github.com/google/uuid v1.6.0
go.mongodb.org/mongo-driver v1.17.3
)
require (
github.com/golang/snappy v0.0.4 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

52
go.sum Normal file
View file

@ -0,0 +1,52 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -0,0 +1,15 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"net/http"
)
// HealthHandler returns a handler for health check endpoint
func HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}

View file

@ -0,0 +1,27 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"net/http"
"github.com/modelcontextprotocol/registry/internal/config"
)
// PingHandler returns a handler for the ping endpoint that returns build version
func PingHandler(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "ok",
"version": cfg.Version,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}

View file

@ -0,0 +1,125 @@
// Package v0 contains API handlers for version 0 of the API
package v0
import (
"encoding/json"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/modelcontextprotocol/registry/internal/service"
)
// Response is a paginated API response
type PaginatedResponse struct {
Data []model.Entry `json:"servers"`
Metadata Metadata `json:"metadata,omitempty"`
}
// Metadata contains pagination metadata
type Metadata struct {
NextCursor string `json:"next_cursor,omitempty"`
}
// ServersHandler returns a handler for listing registry items
func ServersHandler(registry service.RegistryService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse cursor and limit from query parameters
cursor := r.URL.Query().Get("cursor")
if cursor != "" {
_, err := uuid.Parse(cursor)
if err != nil {
http.Error(w, "Invalid cursor parameter", http.StatusBadRequest)
return
}
}
limitStr := r.URL.Query().Get("limit")
// Default limit if not specified
limit := 30
// Try to parse limit from query param
if limitStr != "" {
parsedLimit, err := strconv.Atoi(limitStr)
if err != nil {
http.Error(w, "Invalid limit parameter", http.StatusBadRequest)
return
}
// Check if limit is within reasonable bounds
if parsedLimit <= 0 {
http.Error(w, "Limit must be greater than 0", http.StatusBadRequest)
return
}
if parsedLimit > 100 {
// Cap maximum limit to prevent excessive queries
limit = 100
} else {
limit = parsedLimit
}
}
// Use the GetAll method to get paginated results
registries, nextCursor, err := registry.List(cursor, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Create paginated response
response := PaginatedResponse{
Data: registries,
}
// Add metadata if there's a next cursor
if nextCursor != "" {
response.Metadata = Metadata{
NextCursor: nextCursor,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// ServersDetailHandler returns a handler for getting details of a specific server by ID
func ServersDetailHandler(registry service.RegistryService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract the server ID from the URL path
id := r.PathValue("id")
// Validate that the ID is a valid UUID
_, err := uuid.Parse(id)
if err != nil {
http.Error(w, "Invalid server ID format", http.StatusBadRequest)
return
}
// Get the server details from the registry service
serverDetail, err := registry.GetByID(id)
if err != nil {
if err.Error() == "record not found" {
http.Error(w, "Server not found", http.StatusNotFound)
return
}
http.Error(w, "Error retrieving server details", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(serverDetail)
}
}

View file

@ -0,0 +1,19 @@
// Package router contains API routing logic
package router
import (
"net/http"
"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 {
mux := http.NewServeMux()
// Register routes for all API versions
RegisterV0Routes(mux, cfg, registry)
return mux
}

19
internal/api/router/v0.go Normal file
View file

@ -0,0 +1,19 @@
// Package router contains API routing logic
package router
import (
"net/http"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"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) {
// 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))
}

48
internal/api/server.go Normal file
View file

@ -0,0 +1,48 @@
package api
import (
"context"
"log"
"net/http"
"github.com/modelcontextprotocol/registry/internal/api/router"
"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
}
// NewServer creates a new HTTP server
func NewServer(cfg *config.Config, registryService service.RegistryService) *Server {
// Create router with all API versions registered
mux := router.New(cfg, registryService)
server := &Server{
config: cfg,
registry: registryService,
router: mux,
server: &http.Server{
Addr: cfg.ServerAddress,
Handler: mux,
},
}
return server
}
// Start begins listening for incoming HTTP requests
func (s *Server) Start() error {
log.Printf("HTTP server starting on %s", s.config.ServerAddress)
return s.server.ListenAndServe()
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
return s.server.Shutdown(ctx)
}

56
internal/config/config.go Normal file
View file

@ -0,0 +1,56 @@
package config
import "os"
// Config holds the application configuration
type Config struct {
ServerAddress string
DatabaseURL string
DatabaseName string
CollectionName string
LogLevel string
Environment string // Added environment field
Version string // Application version
}
// NewConfig creates a new configuration with default values
func NewConfig() *Config {
// Get environment from OS environment variable or default to "production"
env := os.Getenv("APP_ENV")
if env == "" {
env = "production"
}
// Get version from environment variable or default to "dev"
version := os.Getenv("APP_VERSION")
if version == "" {
version = "dev"
}
// Get database URL from environment variable or default to localhost
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "localhost:5432"
}
// Get database name from environment variable or default to "mcp_registry"
dbName := os.Getenv("DATABASE_NAME")
if dbName == "" {
dbName = "mcp-registry"
}
// Get collection name from environment variable or default to "entries"
collectionName := os.Getenv("COLLECTION_NAME")
if collectionName == "" {
collectionName = "servers_v2"
}
return &Config{
ServerAddress: ":8080",
DatabaseURL: dbURL,
DatabaseName: dbName,
CollectionName: collectionName,
LogLevel: "info",
Environment: env,
Version: version,
}
}

View file

@ -0,0 +1,48 @@
package database
import (
"context"
"errors"
"github.com/modelcontextprotocol/registry/internal/model"
)
// Common database errors
var (
ErrNotFound = errors.New("record not found")
ErrAlreadyExists = errors.New("record already exists")
ErrInvalidInput = errors.New("invalid input")
ErrDatabase = errors.New("database error")
)
// Database defines the interface for database operations on MCPRegistry entries
type Database interface {
// List retrieves all MCPRegistry entries with optional filtering
List(ctx context.Context, filter map[string]interface{}, cursor string, limit int) ([]*model.Entry, string, error)
// GetByID retrieves a single ServerDetail by it's ID
GetByID(ctx context.Context, id string) (*model.ServerDetail, error)
// Close closes the database connection
Close() error
}
// ConnectionType represents the type of database connection
type ConnectionType string
const (
// ConnectionTypeMemory represents an in-memory database connection
ConnectionTypeMemory ConnectionType = "memory"
// ConnectionTypeMongoDB represents a MongoDB database connection
ConnectionTypeMongoDB ConnectionType = "mongodb"
)
// ConnectionInfo provides information about the database connection
type ConnectionInfo struct {
// Type indicates the type of database connection
Type ConnectionType
// IsConnected indicates whether the database is currently connected
IsConnected bool
// Raw provides access to the underlying connection object, which will vary by implementation
// For MongoDB, this will be *mongo.Client
// For MemoryDB, this will be map[string]*model.MCPRegistry
Raw any
}

152
internal/database/memory.go Normal file
View file

@ -0,0 +1,152 @@
package database
import (
"context"
"sort"
"sync"
"github.com/modelcontextprotocol/registry/internal/model"
)
// MemoryDB is an in-memory implementation of the Database interface
type MemoryDB struct {
entries map[string]*model.Entry
mu sync.RWMutex
}
// NewMemoryDB creates a new instance of the in-memory database
func NewMemoryDB(e map[string]*model.Entry) *MemoryDB {
return &MemoryDB{
entries: e,
}
}
// List retrieves all MCPRegistry entries with optional filtering and pagination
func (db *MemoryDB) List(ctx context.Context, filter map[string]interface{}, cursor string, limit int) ([]*model.Entry, string, error) {
if ctx.Err() != nil {
return nil, "", ctx.Err()
}
if limit <= 0 {
limit = 10 // Default limit
}
db.mu.RLock()
defer db.mu.RUnlock()
// Convert all entries to a slice for pagination
var allEntries []*model.Entry
for _, entry := range db.entries {
entryCopy := *entry
allEntries = append(allEntries, &entryCopy)
}
// Simple filtering implementation
var filteredEntries []*model.Entry
for _, entry := range allEntries {
include := true
// Apply filters if any
if filter != nil {
for key, value := range filter {
switch key {
case "name":
if entry.Name != value.(string) {
include = false
}
case "repoUrl":
if entry.Repository.URL != value.(string) {
include = false
}
case "serverDetail.id":
if entry.ID != value.(string) {
include = false
}
case "version":
if entry.Version != value.(string) {
include = false
}
// Add more filter options as needed
}
}
}
if include {
filteredEntries = append(filteredEntries, entry)
}
}
// Find starting point for cursor-based pagination
startIdx := 0
if cursor != "" {
for i, entry := range filteredEntries {
if entry.ID == cursor {
startIdx = i + 1 // Start after the cursor
break
}
}
}
// Sort filteredEntries by ID for consistent pagination
sort.Slice(filteredEntries, func(i, j int) bool {
return filteredEntries[i].ID < filteredEntries[j].ID
})
// Apply pagination
endIdx := startIdx + limit
if endIdx > len(filteredEntries) {
endIdx = len(filteredEntries)
}
var result []*model.Entry
if startIdx < len(filteredEntries) {
result = filteredEntries[startIdx:endIdx]
} else {
result = []*model.Entry{}
}
// Determine next cursor
nextCursor := ""
if endIdx < len(filteredEntries) {
nextCursor = filteredEntries[endIdx-1].ID
}
return result, nextCursor, nil
}
// GetByID retrieves a single ServerDetail by its ID
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
db.mu.RLock()
defer db.mu.RUnlock()
if entry, exists := db.entries[id]; exists {
return &model.ServerDetail{
ID: entry.ID,
Name: entry.Name,
Description: entry.Description,
Version: entry.Version,
Repository: entry.Repository,
}, nil
}
return nil, ErrNotFound
}
// Close closes the database connection
// For an in-memory database, this is a no-op
func (db *MemoryDB) Close() error {
return nil
}
// Connection returns information about the database connection
func (db *MemoryDB) Connection() *ConnectionInfo {
return &ConnectionInfo{
Type: ConnectionTypeMemory,
IsConnected: true, // Memory DB is always connected
Raw: db.entries,
}
}

189
internal/database/mongo.go Normal file
View file

@ -0,0 +1,189 @@
package database
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/model"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// MongoDB is an implementation of the Database interface using MongoDB
type MongoDB struct {
client *mongo.Client
database *mongo.Database
collection *mongo.Collection
}
// NewMongoDB creates a new instance of the MongoDB database
func NewMongoDB(ctx context.Context, connectionURI, databaseName, collectionName string) (*MongoDB, error) {
// Set client options and connect to MongoDB
clientOptions := options.Client().ApplyURI(connectionURI)
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
return nil, err
}
// Ping the MongoDB server to verify the connection
if err = client.Ping(ctx, nil); err != nil {
return nil, err
}
// Get database and collection
database := client.Database(databaseName)
collection := database.Collection(collectionName)
// Create indexes for better query performance
models := []mongo.IndexModel{
{
Keys: bson.D{{Key: "name", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "id", Value: 1}},
Options: options.Index().SetUnique(true),
},
}
_, err = collection.Indexes().CreateMany(ctx, models)
if err != nil {
return nil, err
}
return &MongoDB{
client: client,
database: database,
collection: collection,
}, nil
}
// List retrieves MCPRegistry entries with optional filtering and pagination
func (db *MongoDB) List(ctx context.Context, filter map[string]interface{}, cursor string, limit int) ([]*model.Entry, string, error) {
if limit <= 0 {
// Set default limit if not provided
limit = 10
}
if ctx.Err() != nil {
return nil, "", ctx.Err()
}
// Convert Go map to MongoDB filter
mongoFilter := bson.M{}
// Map common filter keys to MongoDB document paths
for k, v := range filter {
// Handle nested fields with dot notation
switch k {
case "publisher.trusted":
mongoFilter["publisher.trusted"] = v
default:
mongoFilter[k] = v
}
}
// Setup pagination options
findOptions := options.Find()
// If cursor is provided, add condition to filter to only get records after the cursor
if cursor != "" {
// Validate that the cursor is a valid UUID
if _, err := uuid.Parse(cursor); err != nil {
return nil, "", fmt.Errorf("invalid cursor format: %w", err)
}
// Fetch the document at the cursor to get its sort values
var cursorDoc model.Entry
err := db.collection.FindOne(ctx, bson.M{"id": cursor}).Decode(&cursorDoc)
if err != nil {
if err == mongo.ErrNoDocuments {
// If cursor document not found, start from beginning
} else {
return nil, "", err
}
} else {
// Use the cursor document's ID to paginate (records with ID > cursor's ID)
mongoFilter["id"] = bson.M{"$gt": cursor}
}
}
// Set sort order by ID (for consistent pagination)
findOptions.SetSort(bson.M{"id": 1})
// Set limit if provided and valid
if limit > 0 {
findOptions.SetLimit(int64(limit))
}
// Execute find operation with options
mongoCursor, err := db.collection.Find(ctx, mongoFilter, findOptions)
if err != nil {
return nil, "", err
}
defer mongoCursor.Close(ctx)
// Decode results
var results []*model.Entry
if err = mongoCursor.All(ctx, &results); err != nil {
return nil, "", err
}
// Determine the next cursor
nextCursor := ""
if len(results) > 0 && limit > 0 && len(results) >= limit {
// Use the last item's ID as the next cursor
nextCursor = results[len(results)-1].ID
}
return results, nextCursor, nil
}
// GetByID retrieves a single ServerDetail by its ID
func (db *MongoDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Create a filter for the ID
filter := bson.M{"id": id}
// Find the entry in the database
var entry model.ServerDetail
err := db.collection.FindOne(ctx, filter).Decode(&entry)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, ErrNotFound
}
return nil, fmt.Errorf("error retrieving entry: %w", err)
}
// Create and return a ServerDetail from the entry data
return &entry, nil
}
// Close closes the database connection
func (db *MongoDB) Close() error {
return db.client.Disconnect(context.Background())
}
// Connection returns information about the database connection
func (db *MongoDB) Connection() *ConnectionInfo {
isConnected := false
// Check if the client is connected
if db.client != nil {
// A quick ping with 1 second timeout to verify connection
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := db.client.Ping(ctx, nil)
isConnected = (err == nil)
}
return &ConnectionInfo{
Type: ConnectionTypeMongoDB,
IsConnected: isConnected,
Raw: db.client,
}
}

79
internal/model/model.go Normal file
View file

@ -0,0 +1,79 @@
package model
type Entry struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Repository Repository `json:"repository,omitempty"`
Version string `json:"version,omitempty"`
}
type Repository struct {
URL string `json:"url,omitempty"`
SubFolder string `json:"subfolder,omitempty"`
Branch string `json:"branch,omitempty"`
Commit string `json:"commit,omitempty"`
}
type ServerDetail struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Version string `json:"version,omitempty"`
Repository Repository `json:"repository,omitempty"`
RegistryCanonical string `json:"registry_canonical,omitempty"`
Registries []Registries `json:"registries,omitempty"`
Remotes []Remotes `json:"remotes,omitempty"`
}
type Registries struct {
Name string `json:"name,omitempty"`
PackageName string `json:"package_name,omitempty"`
License string `json:"license,omitempty"`
CommandArguments Command `json:"command_arguments,omitempty"`
}
type Remotes struct {
TransportType string `json:"transport_type,omitempty"`
Url string `json:"url,omitempty"`
}
type Command struct {
SubCommands []SubCommand `json:"sub_commands,omitempty"`
PositionalArguments []PositionalArgument `json:"positional_arguments,omitempty"`
EnvironmentVariables []EnvironmentVariable `json:"environment_variables,omitempty"`
NamedArguments []NamedArguments `json:"named_arguments,omitempty"`
}
type EnvironmentVariable struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
}
type Argument struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
DefaultValue string `json:"default_value,omitempty"`
IsRequired bool `json:"is_required,omitempty"`
IsEditable bool `json:"is_editable,omitempty"`
IsRepeatable bool `json:"is_repeatable,omitempty"`
Choices []string `json:"choices,omitempty"`
}
type PositionalArgument struct {
Position int `json:"position,omitempty"`
Argument Argument `json:"argument,omitempty"`
}
type SubCommand struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
NamedArguments []NamedArguments `json:"named_arguments,omitempty"`
}
type NamedArguments struct {
ShortFlag string `json:"short_flag,omitempty"`
LongFlag string `json:"long_flag,omitempty"`
RequiresValue bool `json:"requires_value,omitempty"`
Argument Argument `json:"argument,omitempty"`
}

Some files were not shown because too many files have changed in this diff Show more