├── .dockerignore ├── .github └── workflows │ ├── ci.yml │ ├── integration-tests.yml │ └── unit-tests.yml ├── .gitignore ├── .golangci.yml ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── registry │ ├── main.go │ └── version.go ├── data └── seed_2025_05_16.json ├── docker-compose.yml ├── docs ├── MCP Developers Summit 2025 - Registry Talk Slides.pdf ├── README.md ├── api_examples.md └── openapi.yaml ├── go.mod ├── go.sum ├── integrationtests ├── README.md ├── publish_integration_test.go └── run_tests.sh ├── internal ├── api │ ├── handlers │ │ └── v0 │ │ │ ├── auth.go │ │ │ ├── health.go │ │ │ ├── health_test.go │ │ │ ├── ping.go │ │ │ ├── publish.go │ │ │ ├── publish_test.go │ │ │ ├── servers.go │ │ │ ├── servers_test.go │ │ │ └── swagger.go │ ├── router │ │ ├── router.go │ │ └── v0.go │ └── server.go ├── auth │ ├── auth.go │ ├── github.go │ └── service.go ├── config │ └── config.go ├── database │ ├── database.go │ ├── import.go │ ├── memory.go │ └── mongo.go ├── docs │ ├── publish_swagger.yaml │ └── swagger.yaml ├── model │ └── model.go └── service │ ├── fake_service.go │ ├── registry_service.go │ └── service.go ├── scripts ├── test_endpoints.sh └── test_publish.sh └── tools └── publisher ├── README.md ├── auth ├── README.md ├── github │ └── oauth.go └── interface.go ├── build.sh ├── main.go └── server.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .db 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | env: 10 | GO_VERSION: '1.23.x' 11 | 12 | jobs: 13 | # Lint and Format Check 14 | lint: 15 | name: Lint and Format 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ env.GO_VERSION }} 25 | 26 | - name: Install golangci-lint 27 | run: | 28 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.61.0 29 | 30 | - name: Run golangci-lint 31 | run: golangci-lint run --timeout=5m 32 | 33 | - name: Check Go formatting 34 | run: | 35 | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then 36 | echo "The following files are not properly formatted:" 37 | gofmt -s -l . 38 | exit 1 39 | fi 40 | 41 | # Build check 42 | build: 43 | name: Build Check 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v4 48 | 49 | - name: Set up Go 50 | uses: actions/setup-go@v5 51 | with: 52 | go-version: ${{ env.GO_VERSION }} 53 | 54 | - name: Cache Go modules 55 | uses: actions/cache@v4 56 | with: 57 | path: | 58 | ~/.cache/go-build 59 | ~/go/pkg/mod 60 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 61 | restore-keys: | 62 | ${{ runner.os }}-go- 63 | 64 | - name: Download dependencies 65 | run: go mod download 66 | 67 | - name: Build application 68 | run: | 69 | go build -v ./cmd/... 70 | 71 | - name: Check for vulnerabilities 72 | run: | 73 | go install golang.org/x/vuln/cmd/govulncheck@latest 74 | govulncheck ./... 75 | 76 | # Unit Tests 77 | unit-tests: 78 | name: Unit Tests 79 | runs-on: ubuntu-latest 80 | needs: [lint, build] 81 | steps: 82 | - name: Checkout code 83 | uses: actions/checkout@v4 84 | 85 | - name: Set up Go 86 | uses: actions/setup-go@v5 87 | with: 88 | go-version: ${{ env.GO_VERSION }} 89 | 90 | - name: Cache Go modules 91 | uses: actions/cache@v4 92 | with: 93 | path: | 94 | ~/.cache/go-build 95 | ~/go/pkg/mod 96 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 97 | restore-keys: | 98 | ${{ runner.os }}-go- 99 | 100 | - name: Download dependencies 101 | run: go mod download 102 | 103 | - name: Run unit tests 104 | run: | 105 | go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/... 106 | 107 | - name: Upload coverage to Codecov 108 | uses: codecov/codecov-action@v4 109 | with: 110 | file: ./coverage.out 111 | flags: unittests 112 | name: codecov-unit 113 | fail_ci_if_error: false 114 | 115 | # Integration Tests 116 | integration-tests: 117 | name: Integration Tests 118 | runs-on: ubuntu-latest 119 | needs: [lint, build] 120 | steps: 121 | - name: Checkout code 122 | uses: actions/checkout@v4 123 | 124 | - name: Set up Go 125 | uses: actions/setup-go@v5 126 | with: 127 | go-version: ${{ env.GO_VERSION }} 128 | 129 | - name: Cache Go modules 130 | uses: actions/cache@v4 131 | with: 132 | path: | 133 | ~/.cache/go-build 134 | ~/go/pkg/mod 135 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 136 | restore-keys: | 137 | ${{ runner.os }}-go- 138 | 139 | - name: Download dependencies 140 | run: go mod download 141 | 142 | - name: Run integration tests 143 | run: | 144 | chmod +x ./integrationtests/run_tests.sh 145 | ./integrationtests/run_tests.sh 146 | 147 | - name: Run integration tests with coverage 148 | run: | 149 | go test -v -race -coverprofile=integration-coverage.out -covermode=atomic ./integrationtests/... 150 | 151 | - name: Upload integration coverage to Codecov 152 | uses: codecov/codecov-action@v4 153 | with: 154 | file: ./integration-coverage.out 155 | flags: integrationtests 156 | name: codecov-integration 157 | fail_ci_if_error: false 158 | 159 | # Overall status check 160 | test-summary: 161 | name: Test Summary 162 | runs-on: ubuntu-latest 163 | needs: [unit-tests, integration-tests] 164 | if: always() 165 | steps: 166 | - name: Check test results 167 | run: | 168 | if [[ "${{ needs.unit-tests.result }}" == "success" && "${{ needs.integration-tests.result }}" == "success" ]]; then 169 | echo "✅ All tests passed!" 170 | exit 0 171 | else 172 | echo "❌ Some tests failed:" 173 | echo " Unit tests: ${{ needs.unit-tests.result }}" 174 | echo " Integration tests: ${{ needs.integration-tests.result }}" 175 | exit 1 176 | fi 177 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | jobs: 10 | integration-tests: 11 | name: Run Integration Tests 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | go-version: ['1.23.x'] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: ${{ matrix.go-version }} 26 | 27 | - name: Cache Go modules 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cache/go-build 32 | ~/go/pkg/mod 33 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 34 | restore-keys: | 35 | ${{ runner.os }}-go- 36 | 37 | - name: Download dependencies 38 | run: go mod download 39 | 40 | - name: Verify dependencies 41 | run: go mod verify 42 | 43 | - name: Set up test environment 44 | run: | 45 | # Create any necessary directories for test data 46 | mkdir -p /tmp/test-data 47 | 48 | - name: Run integration tests 49 | run: | 50 | # Run integration tests using the existing script 51 | chmod +x ./integrationtests/run_tests.sh 52 | ./integrationtests/run_tests.sh 53 | 54 | - name: Run integration tests with coverage 55 | run: | 56 | # Also run integration tests with Go test for coverage 57 | go test -v -race -coverprofile=integration-coverage.out -covermode=atomic ./integrationtests/... 58 | 59 | - name: Generate integration test coverage report 60 | run: go tool cover -html=integration-coverage.out -o integration-coverage.html 61 | 62 | - name: Upload integration coverage to Codecov 63 | uses: codecov/codecov-action@v4 64 | with: 65 | file: ./integration-coverage.out 66 | flags: integrationtests 67 | name: codecov-integration 68 | fail_ci_if_error: false 69 | 70 | - name: Upload integration coverage artifact 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: integration-coverage-report 74 | path: integration-coverage.html 75 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | jobs: 10 | unit-tests: 11 | name: Run Unit Tests 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | go-version: ['1.23.x'] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: ${{ matrix.go-version }} 26 | 27 | - name: Cache Go modules 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cache/go-build 32 | ~/go/pkg/mod 33 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 34 | restore-keys: | 35 | ${{ runner.os }}-go- 36 | 37 | - name: Download dependencies 38 | run: go mod download 39 | 40 | - name: Verify dependencies 41 | run: go mod verify 42 | 43 | - name: Run unit tests 44 | run: | 45 | # Run unit tests with coverage, excluding integration tests 46 | go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/... 47 | 48 | - name: Generate coverage report 49 | run: go tool cover -html=coverage.out -o coverage.html 50 | 51 | - name: Upload coverage to Codecov 52 | uses: codecov/codecov-action@v4 53 | with: 54 | file: ./coverage.out 55 | flags: unittests 56 | name: codecov-umbrella 57 | fail_ci_if_error: false 58 | 59 | - name: Upload coverage artifact 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: coverage-report 63 | path: coverage.html 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/* 2 | .db 3 | .env 4 | .mcpregistry* 5 | **/bin 6 | cmd/registry/registry 7 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # GolangCI-Lint configuration 2 | # See: https://golangci-lint.run/usage/configuration/ 3 | 4 | run: 5 | timeout: 5m 6 | modules-download-mode: readonly 7 | 8 | linters: 9 | enable: 10 | - errcheck 11 | - gosimple 12 | - govet 13 | - ineffassign 14 | - staticcheck 15 | - typecheck 16 | - unused 17 | - asasalint 18 | - asciicheck 19 | - bidichk 20 | - bodyclose 21 | - containedctx 22 | - contextcheck 23 | - cyclop 24 | - dupl 25 | - durationcheck 26 | - errname 27 | - errorlint 28 | - exhaustive 29 | - forbidigo 30 | - funlen 31 | - gci 32 | - gocognit 33 | - goconst 34 | - gocritic 35 | - gocyclo 36 | - godox 37 | - gofmt 38 | - goimports 39 | - gomoddirectives 40 | - gomodguard 41 | - goprintffuncname 42 | - gosec 43 | - grouper 44 | - importas 45 | - ireturn 46 | - lll 47 | - makezero 48 | - misspell 49 | - nakedret 50 | - nestif 51 | - nilerr 52 | - nilnil 53 | - noctx 54 | - nolintlint 55 | - nosprintfhostport 56 | - predeclared 57 | - promlinter 58 | - reassign 59 | - revive 60 | - rowserrcheck 61 | - sqlclosecheck 62 | - stylecheck 63 | - tenv 64 | - testpackage 65 | - thelper 66 | - tparallel 67 | - unconvert 68 | - unparam 69 | - usestdlibvars 70 | - wastedassign 71 | - whitespace 72 | 73 | linters-settings: 74 | cyclop: 75 | max-complexity: 50 76 | funlen: 77 | lines: 150 78 | statements: 150 79 | gocognit: 80 | min-complexity: 50 81 | gocyclo: 82 | min-complexity: 25 83 | goconst: 84 | min-len: 3 85 | min-occurrences: 3 86 | mnd: 87 | checks: 88 | - argument 89 | - case 90 | - condition 91 | - operation 92 | - return 93 | lll: 94 | line-length: 150 95 | misspell: 96 | locale: US 97 | nestif: 98 | min-complexity: 8 99 | 100 | issues: 101 | exclude-rules: 102 | # Exclude some linters from running on tests files. 103 | - path: _test\.go 104 | linters: 105 | - mnd 106 | - funlen 107 | - gocyclo 108 | - errcheck 109 | - dupl 110 | - gosec 111 | # Ignore long lines in generated code 112 | - path: docs/ 113 | linters: 114 | - lll 115 | # Ignore magic numbers in test files 116 | - path: integrationtests/ 117 | linters: 118 | - mnd 119 | # Allow local replacement directives in go.mod 120 | - path: go\.mod 121 | linters: 122 | - gomoddirectives 123 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Launch server", 10 | "type": "go", 11 | "request": "launch", 12 | "mode": "auto", 13 | "program": "${workspaceFolder}/cmd/registry", 14 | "envFile": "${workspaceFolder}/.env", 15 | }, 16 | { 17 | "name": "Launch publisher", 18 | "type": "go", 19 | "request": "launch", 20 | "mode": "auto", 21 | "program": "${workspaceFolder}/tools/publisher/main.go", 22 | "args": [ 23 | "-registry-url=http://localhost:8080", 24 | "-mcp-file=${workspaceFolder}/tools/publisher/server.json", 25 | ], 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Use [Discussions](https://github.com/modelcontextprotocol/registry/discussions) to propose and discuss product and/or technical **requirements**. 4 | 5 | Use [Issues](https://github.com/modelcontextprotocol/registry/issues) to track **well-scoped technical work** that the community agrees should be done at some point. 6 | 7 | Open [Pull Requests](https://github.com/modelcontextprotocol/registry/pulls) when you want to **contribute work towards an Issue**, or you feel confident that your contribution is desireable and small enough to forego community discussion at the requirements and planning levels. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS builder 2 | WORKDIR /app 3 | COPY . . 4 | RUN go build -o /build/registry ./cmd/registry 5 | 6 | FROM alpine:latest 7 | WORKDIR /app 8 | COPY --from=builder /build/registry . 9 | COPY --from=builder /app/data/seed_2025_05_16.json /app/data/seed.json 10 | COPY --from=builder /app/internal/docs/swagger.yaml /app/internal/docs/swagger.yaml 11 | EXPOSE 8080 12 | 13 | ENTRYPOINT ["./registry"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Registry 2 | 3 | A community driven registry service for Model Context Protocol (MCP) servers. 4 | 5 | ## Development Status 6 | 7 | This project is being built in the open and is currently in the early stages of development. Please see the [overview discussion](https://github.com/modelcontextprotocol/registry/discussions/11) for the project scope and goals. If you would like to contribute, please check out the [contributing guidelines](CONTRIBUTING.md). 8 | 9 | ## Overview 10 | 11 | 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. 12 | 13 | ## Features 14 | 15 | - RESTful API for managing MCP registry entries (list, get, create, update, delete) 16 | - Health check endpoint for service monitoring 17 | - Support for various environment configurations 18 | - Graceful shutdown handling 19 | - MongoDB and in-memory database support 20 | - Comprehensive API documentation 21 | - Pagination support for listing registry entries 22 | 23 | ## Getting Started 24 | 25 | ### Prerequisites 26 | 27 | - Go 1.18 or later 28 | - MongoDB 29 | - Docker (optional, but recommended for development) 30 | 31 | ## Running 32 | 33 | The easiest way to get the registry running is to use `docker compose`. This will setup the MCP Registry service, import the seed data and run MongoDB in a local Docker environment. 34 | 35 | ```bash 36 | # Build the Docker image 37 | docker build -t registry . 38 | 39 | # Run the registry and MongoDB with docker compose 40 | docker compose up 41 | ``` 42 | 43 | This will start the MCP Registry service and MongoDB with Docker, exposing it on port 8080. 44 | 45 | ## Building 46 | 47 | If you prefer to run the service locally without Docker, you can build and run it directly using Go. 48 | 49 | ```bash 50 | # Build a registry executable 51 | go build ./cmd/registry 52 | ``` 53 | This will create the `registry` binary in the current directory. You'll need to have MongoDB running locally or with Docker. 54 | 55 | By default, the service will run on `http://localhost:8080`. 56 | 57 | ## Project Structure 58 | 59 | ``` 60 | ├── api/ # OpenApi specification 61 | ├── cmd/ # Application entry points 62 | ├── config/ # Configuration files 63 | ├── internal/ # Private application code 64 | │ ├── api/ # HTTP server and request handlers 65 | │ ├── config/ # Configuration management 66 | │ ├── model/ # Data models 67 | │ └── service/ # Business logic 68 | ├── pkg/ # Public libraries 69 | ├── scripts/ # Utility scripts 70 | └── tools/ # Command line tools 71 | └── publisher/ # Tool to publish MCP servers to the registry 72 | ``` 73 | 74 | ## API Documentation 75 | 76 | The API is documented using Swagger/OpenAPI. You can access the interactive Swagger UI at: 77 | 78 | ``` 79 | /v0/swagger/index.html 80 | ``` 81 | 82 | This provides a complete reference of all endpoints with request/response schemas and allows you to test the API directly from your browser. 83 | 84 | ## API Endpoints 85 | 86 | ### Health Check 87 | 88 | ``` 89 | GET /v0/health 90 | ``` 91 | 92 | Returns the health status of the service: 93 | ```json 94 | { 95 | "status": "ok" 96 | } 97 | ``` 98 | 99 | ### Registry Endpoints 100 | 101 | #### List Registry Server Entries 102 | 103 | ``` 104 | GET /v0/servers 105 | ``` 106 | 107 | Lists MCP registry server entries with pagination support. 108 | 109 | Query parameters: 110 | - `limit`: Maximum number of entries to return (default: 30, max: 100) 111 | - `cursor`: Pagination cursor for retrieving next set of results 112 | 113 | Response example: 114 | ```json 115 | { 116 | "servers": [ 117 | { 118 | "id": "123e4567-e89b-12d3-a456-426614174000", 119 | "name": "Example MCP Server", 120 | "url": "https://example.com/mcp", 121 | "description": "An example MCP server", 122 | "created_at": "2025-05-17T17:34:22.912Z", 123 | "updated_at": "2025-05-17T17:34:22.912Z" 124 | } 125 | ], 126 | "metadata": { 127 | "next_cursor": "123e4567-e89b-12d3-a456-426614174000", 128 | "count": 30 129 | } 130 | } 131 | ``` 132 | 133 | #### Get Server Details 134 | 135 | ``` 136 | GET /v0/servers/{id} 137 | ``` 138 | 139 | Retrieves detailed information about a specific MCP server entry. 140 | 141 | Path parameters: 142 | - `id`: Unique identifier of the server entry 143 | 144 | Response example: 145 | ```json 146 | { 147 | "id": "01129bff-3d65-4e3d-8e82-6f2f269f818c", 148 | "name": "io.github.gongrzhe/redis-mcp-server", 149 | "description": "A Redis MCP server (pushed to https://github.com/modelcontextprotocol/servers/tree/main/src/redis) implementation for interacting with Redis databases. This server enables LLMs to interact with Redis key-value stores through a set of standardized tools.", 150 | "repository": { 151 | "url": "https://github.com/GongRzhe/REDIS-MCP-Server", 152 | "source": "github", 153 | "id": "907849235" 154 | }, 155 | "version_detail": { 156 | "version": "0.0.1-seed", 157 | "release_date": "2025-05-16T19:13:21Z", 158 | "is_latest": true 159 | }, 160 | "packages": [ 161 | { 162 | "registry_name": "docker", 163 | "name": "@gongrzhe/server-redis-mcp", 164 | "version": "1.0.0", 165 | "package_arguments": [ 166 | { 167 | "description": "Docker image to run", 168 | "is_required": true, 169 | "format": "string", 170 | "value": "mcp/redis", 171 | "default": "mcp/redis", 172 | "type": "positional", 173 | "value_hint": "mcp/redis" 174 | }, 175 | { 176 | "description": "Redis server connection string", 177 | "is_required": true, 178 | "format": "string", 179 | "value": "redis://host.docker.internal:6379", 180 | "default": "redis://host.docker.internal:6379", 181 | "type": "positional", 182 | "value_hint": "host.docker.internal:6379" 183 | } 184 | ] 185 | } 186 | ] 187 | } 188 | ``` 189 | 190 | #### Publish a Server Entry 191 | 192 | ``` 193 | POST /v0/publish 194 | ``` 195 | 196 | Publishes a new MCP server entry to the registry. Authentication is required via Bearer token in the Authorization header. 197 | 198 | Headers: 199 | - `Authorization`: Bearer token for authentication (e.g., `Bearer your_token_here`) 200 | - `Content-Type`: application/json 201 | 202 | Request body example: 203 | ```json 204 | { 205 | "description": "", 206 | "name": "io.github./", 207 | "packages": [ 208 | { 209 | "registry_name": "npm", 210 | "name": "@/", 211 | "version": "0.2.23", 212 | "package_arguments": [ 213 | { 214 | "description": "Specify services and permissions.", 215 | "is_required": true, 216 | "format": "string", 217 | "value": "-s", 218 | "default": "-s", 219 | "type": "positional", 220 | "value_hint": "-s" 221 | } 222 | ], 223 | "environment_variables": [ 224 | { 225 | "description": "API Key to access the server", 226 | "name": "API_KEY" 227 | } 228 | ] 229 | },{ 230 | "registry_name": "docker", 231 | "name": "@/-cli", 232 | "version": "0.123.223", 233 | "runtime_hint": "docker", 234 | "runtime_arguments": [ 235 | { 236 | "description": "Specify services and permissions.", 237 | "is_required": true, 238 | "format": "string", 239 | "value": "--mount", 240 | "default": "--mount", 241 | "type": "positional", 242 | "value_hint": "--mount" 243 | } 244 | ], 245 | "environment_variables": [ 246 | { 247 | "description": "API Key to access the server", 248 | "name": "API_KEY" 249 | } 250 | ] 251 | } 252 | ], 253 | "repository": { 254 | "url": "https://github.com//", 255 | "source": "github" 256 | }, 257 | "version_detail": { 258 | "version": "0.0.1-" 259 | } 260 | } 261 | ``` 262 | 263 | Response example: 264 | ```json 265 | { 266 | "message": "Server publication successful", 267 | "id": "1234567890abcdef12345678" 268 | } 269 | ``` 270 | 271 | ### Ping Endpoint 272 | 273 | ``` 274 | GET /v0/ping 275 | ``` 276 | 277 | Simple ping endpoint that returns environment configuration information: 278 | ```json 279 | { 280 | "environment": "dev", 281 | "version": "registry-" 282 | } 283 | ``` 284 | 285 | ## Configuration 286 | 287 | The service can be configured using environment variables: 288 | 289 | | Variable | Description | Default | 290 | |----------|-------------|---------| 291 | | `MCP_REGISTRY_APP_VERSION` | Application version | `dev` | 292 | | `MCP_REGISTRY_DATABASE_TYPE` | Database type | `mongodb` | 293 | | `MCP_REGISTRY_COLLECTION_NAME` | MongoDB collection name | `servers_v2` | 294 | | `MCP_REGISTRY_DATABASE_NAME` | MongoDB database name | `mcp-registry` | 295 | | `MCP_REGISTRY_DATABASE_URL` | MongoDB connection string | `mongodb://localhost:27017` | 296 | | `MCP_REGISTRY_GITHUB_CLIENT_ID` | GitHub App Client ID | | 297 | | `MCP_REGISTRY_GITHUB_CLIENT_SECRET` | GitHub App Client Secret | | 298 | | `MCP_REGISTRY_LOG_LEVEL` | Log level | `info` | 299 | | `MCP_REGISTRY_SEED_FILE_PATH` | Path to import seed file | `data/seed.json` | 300 | | `MCP_REGISTRY_SEED_IMPORT` | Import `seed.json` on first run | `true` | 301 | | `MCP_REGISTRY_SERVER_ADDRESS` | Listen address for the server | `:8080` | 302 | 303 | 304 | ## Testing 305 | 306 | Run the test script to validate API endpoints: 307 | 308 | ```bash 309 | ./scripts/test_endpoints.sh 310 | ``` 311 | 312 | You can specify specific endpoints to test: 313 | 314 | ```bash 315 | ./scripts/test_endpoints.sh --endpoint health 316 | ./scripts/test_endpoints.sh --endpoint servers 317 | ``` 318 | 319 | ## License 320 | 321 | See the [LICENSE](LICENSE) file for details. 322 | 323 | ## Contributing 324 | 325 | See the [CONTRIBUTING](CONTRIBUTING.md) file for details. 326 | -------------------------------------------------------------------------------- /cmd/registry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/modelcontextprotocol/registry/internal/api" 15 | "github.com/modelcontextprotocol/registry/internal/auth" 16 | "github.com/modelcontextprotocol/registry/internal/config" 17 | "github.com/modelcontextprotocol/registry/internal/database" 18 | "github.com/modelcontextprotocol/registry/internal/model" 19 | "github.com/modelcontextprotocol/registry/internal/service" 20 | ) 21 | 22 | func main() { 23 | // Parse command line flags 24 | showVersion := flag.Bool("version", false, "Display version information") 25 | flag.Parse() 26 | 27 | // Show version information if requested 28 | if *showVersion { 29 | log.Printf("MCP Registry v%s\n", Version) 30 | log.Printf("Git commit: %s\n", GitCommit) 31 | log.Printf("Build time: %s\n", BuildTime) 32 | return 33 | } 34 | 35 | log.Printf("Starting MCP Registry Application v%s (commit: %s)", Version, GitCommit) 36 | 37 | var ( 38 | registryService service.RegistryService 39 | db database.Database 40 | err error 41 | ) 42 | 43 | // Initialize configuration 44 | cfg := config.NewConfig() 45 | 46 | // Initialize services based on environment 47 | switch cfg.DatabaseType { 48 | case config.DatabaseTypeMemory: 49 | db = database.NewMemoryDB(map[string]*model.Server{}) 50 | registryService = service.NewRegistryServiceWithDB(db) 51 | case config.DatabaseTypeMongoDB: 52 | // Use MongoDB for real registry service in production/other environments 53 | // Create a context with timeout for MongoDB connection 54 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 55 | defer cancel() 56 | 57 | // Connect to MongoDB 58 | db, err = database.NewMongoDB(ctx, cfg.DatabaseURL, cfg.DatabaseName, cfg.CollectionName) 59 | if err != nil { 60 | log.Printf("Failed to connect to MongoDB: %v", err) 61 | return 62 | } 63 | 64 | // Create registry service with MongoDB 65 | registryService = service.NewRegistryServiceWithDB(db) 66 | log.Printf("MongoDB database name: %s", cfg.DatabaseName) 67 | log.Printf("MongoDB collection name: %s", cfg.CollectionName) 68 | 69 | // Store the MongoDB instance for later cleanup 70 | defer func() { 71 | if err := db.Close(); err != nil { 72 | log.Printf("Error closing MongoDB connection: %v", err) 73 | } else { 74 | log.Println("MongoDB connection closed successfully") 75 | } 76 | }() 77 | default: 78 | log.Printf("Invalid database type: %s; supported types: %s, %s", cfg.DatabaseType, config.DatabaseTypeMemory, config.DatabaseTypeMongoDB) 79 | return 80 | } 81 | 82 | // Import seed data if requested (works for both memory and MongoDB) 83 | if cfg.SeedImport { 84 | log.Println("Importing data...") 85 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 86 | defer cancel() 87 | 88 | if err := db.ImportSeed(ctx, cfg.SeedFilePath); err != nil { 89 | log.Printf("Failed to import seed file: %v", err) 90 | } else { 91 | log.Println("Data import completed successfully") 92 | } 93 | } 94 | 95 | // Initialize authentication services 96 | authService := auth.NewAuthService(cfg) 97 | 98 | // Initialize HTTP server 99 | server := api.NewServer(cfg, registryService, authService) 100 | 101 | // Start server in a goroutine so it doesn't block signal handling 102 | go func() { 103 | if err := server.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { 104 | log.Printf("Failed to start server: %v", err) 105 | os.Exit(1) 106 | } 107 | }() 108 | 109 | // Wait for interrupt signal to gracefully shutdown the server 110 | quit := make(chan os.Signal, 1) 111 | 112 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 113 | <-quit 114 | log.Println("Shutting down server...") 115 | 116 | // Create context with timeout for shutdown 117 | sctx, scancel := context.WithTimeout(context.Background(), 10*time.Second) 118 | defer scancel() 119 | 120 | // Gracefully shutdown the server 121 | if err := server.Shutdown(sctx); err != nil { 122 | log.Printf("Server forced to shutdown: %v", err) 123 | } 124 | 125 | log.Println("Server exiting") 126 | } 127 | -------------------------------------------------------------------------------- /cmd/registry/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Version info for the MCP Registry application 4 | var ( 5 | // Version is the current version of the MCP Registry application 6 | Version = "0.1.0" 7 | 8 | // BuildTime is the time at which the binary was built 9 | BuildTime = "undefined" 10 | 11 | // GitCommit is the git commit that was compiled 12 | GitCommit = "undefined" 13 | ) 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | registry: 3 | image: registry 4 | container_name: registry 5 | links: 6 | - mongodb 7 | depends_on: 8 | - mongodb 9 | environment: 10 | - MCP_REGISTRY_DATABASE_URL=${MCP_REGISTRY_DATABASE_URL:-mongodb://mongodb:27017} 11 | - MCP_REGISTRY_ENVIRONMENT=${MCP_REGISTRY_ENVIRONMENT:-test} 12 | - MCP_REGISTRY_GITHUB_CLIENT_ID=${MCP_REGISTRY_GITHUB_CLIENT_ID} 13 | - MCP_REGISTRY_GITHUB_CLIENT_SECRET=${MCP_REGISTRY_GITHUB_CLIENT_SECRET} 14 | ports: 15 | - 8080:8080 16 | restart: "unless-stopped" 17 | mongodb: 18 | image: mongo 19 | container_name: mongodb 20 | environment: 21 | - PUID=1000 22 | - PGID=1000 23 | volumes: 24 | - './.db:/data/db' 25 | ports: 26 | - 27017:27017 27 | restart: "unless-stopped" 28 | -------------------------------------------------------------------------------- /docs/MCP Developers Summit 2025 - Registry Talk Slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modelcontextprotocol/registry/686fbe1e78117546c91dfb47051e405f0bd1762b/docs/MCP Developers Summit 2025 - Registry Talk Slides.pdf -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Official Registry Documentation 2 | 3 | [`openapi.yaml`](./openapi.yaml) - OpenAPI specification for the official registry API 4 | [`api_examples.md`](./api_examples.md) - Examples of what data will actually look like coming from the official registry API 5 | [`MCP Developers Summit 2025 - Registry Talk Slides.pdf`](./MCP%20Developers%20Summit%202025%20-%20Registry%20Talk%20Slides.pdf) - Slides from a talk given at the MCP Developers Summit on May 23, 2025, with an up-to-date vision of how we are thinking about the official registry. -------------------------------------------------------------------------------- /docs/api_examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## /v0/servers 4 | 5 | ### Request 6 | 7 | ```http 8 | GET /v0/servers?limit=5000&offset=0 9 | ``` 10 | 11 | ### Response 12 | 13 | ```json 14 | { 15 | "servers": [ 16 | { 17 | "id": "a5e8a7f0-d4e4-4a1d-b12f-2896a23fd4f1", 18 | "name": "io.modelcontextprotocol/filesystem", 19 | "description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.", 20 | "repository": { 21 | "url": "https://github.com/modelcontextprotocol/servers", 22 | "source": "github", 23 | "id": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" 24 | }, 25 | "version_detail": { 26 | "version": "1.0.2", 27 | "release_date": "2023-06-15T10:30:00Z", 28 | "is_latest": true 29 | } 30 | } 31 | ], 32 | "next": "https://registry.modelcontextprotocol.io/servers?offset=50", 33 | "total_count": 1 34 | } 35 | ``` 36 | 37 | ## /v0/servers/:id 38 | 39 | ### Request 40 | 41 | ```http 42 | GET /v0/servers/a5e8a7f0-d4e4-4a1d-b12f-2896a23fd4f1?version=0.0.3 43 | ``` 44 | 45 | ### Response 46 | 47 | ```json 48 | { 49 | "id": "a5e8a7f0-d4e4-4a1d-b12f-2896a23fd4f1", 50 | "name": "io.modelcontextprotocol/filesystem", 51 | "description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.", 52 | "repository": { 53 | "url": "https://github.com/modelcontextprotocol/servers", 54 | "source": "github", 55 | "id": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" 56 | }, 57 | "version_detail": { 58 | "version": "1.0.2", 59 | "release_date": "2023-06-15T10:30:00Z", 60 | "is_latest": true 61 | }, 62 | "packages": [ 63 | { 64 | "registry_name": "npm", 65 | "name": "@modelcontextprotocol/server-filesystem", 66 | "version": "1.0.2", 67 | "package_arguments": [ 68 | { 69 | "type": "positional", 70 | "value_hint": "target_dir", 71 | "description": "Path to access", 72 | "default": "/Users/username/Desktop", 73 | "is_required": true, 74 | "is_repeated": true 75 | } 76 | ], 77 | "environment_variables": [ 78 | { 79 | "name": "LOG_LEVEL", 80 | "description": "Logging level (debug, info, warn, error)", 81 | "default": "info" 82 | } 83 | ] 84 | }, 85 | { 86 | "registry_name": "docker", 87 | "name": "mcp/filesystem", 88 | "version": "1.0.2", 89 | "runtime_arguments": [ 90 | { 91 | "type": "named", 92 | "description": "Mount a volume into the container", 93 | "name": "--mount", 94 | "value": "type=bind,src={source_path},dst={target_path}", 95 | "is_required": true, 96 | "is_repeated": true, 97 | "variables": { 98 | "source_path": { 99 | "description": "Source path on host", 100 | "format": "filepath", 101 | "is_required": true 102 | }, 103 | "target_path": { 104 | "description": "Path to mount in the container. It should be rooted in `/project` directory.", 105 | "is_required": true, 106 | "default": "/project", 107 | } 108 | } 109 | } 110 | ], 111 | "package_arguments": [ 112 | { 113 | "type": "positional", 114 | "value_hint": "target_dir", 115 | "value": "/project", 116 | } 117 | ], 118 | "environment_variables": [ 119 | { 120 | "name": "LOG_LEVEL", 121 | "description": "Logging level (debug, info, warn, error)", 122 | "default": "info" 123 | } 124 | ] 125 | } 126 | ], 127 | "remotes": [ 128 | { 129 | "transport_type": "sse", 130 | "url": "https://mcp-fs.example.com/sse" 131 | } 132 | ] 133 | } 134 | ``` 135 | 136 | ### Server Configuration Examples 137 | 138 | #### Local Server with npx 139 | 140 | API Response: 141 | ```json 142 | { 143 | "id": "brave-search-12345", 144 | "name": "io.modelcontextprotocol/brave-search", 145 | "description": "MCP server for Brave Search API integration", 146 | "repository": { 147 | "url": "https://github.com/modelcontextprotocol/servers", 148 | "source": "github", 149 | "id": "abc123de-f456-7890-ghij-klmnopqrstuv" 150 | }, 151 | "version_detail": { 152 | "version": "1.0.2", 153 | "release_date": "2023-06-15T10:30:00Z", 154 | "is_latest": true 155 | }, 156 | "packages": [ 157 | { 158 | "registry_name": "npm", 159 | "name": "@modelcontextprotocol/server-brave-search", 160 | "version": "1.0.2", 161 | "environment_variables": [ 162 | { 163 | "name": "BRAVE_API_KEY", 164 | "description": "Brave Search API Key", 165 | "is_required": true, 166 | "is_secret": true 167 | } 168 | ] 169 | } 170 | ] 171 | } 172 | ``` 173 | 174 | claude_desktop_config.json: 175 | ```json 176 | { 177 | "brave-search": { 178 | "command": "npx", 179 | "args": [ 180 | "-y", 181 | "@modelcontextprotocol/server-brave-search" 182 | ], 183 | "env": { 184 | "BRAVE_API_KEY": "YOUR_API_KEY_HERE" 185 | } 186 | } 187 | } 188 | ``` 189 | 190 | #### Local Server with Docker 191 | 192 | API Response: 193 | ```json 194 | { 195 | "id": "filesystem-67890", 196 | "name": "io.modelcontextprotocol/filesystem", 197 | "description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations", 198 | "repository": { 199 | "url": "https://github.com/modelcontextprotocol/servers", 200 | "source": "github", 201 | "id": "d94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" 202 | }, 203 | "version_detail": { 204 | "version": "1.0.2", 205 | "release_date": "2023-06-15T10:30:00Z", 206 | "is_latest": true 207 | }, 208 | "packages": [ 209 | { 210 | "registry_name": "docker", 211 | "name": "mcp/filesystem", 212 | "version": "1.0.2", 213 | "runtime_arguments": [ 214 | { 215 | "type": "named", 216 | "description": "Mount a volume into the container", 217 | "name": "--mount", 218 | "value": "type=bind,src={source_path},dst={target_path}", 219 | "is_required": true, 220 | "is_repeated": true, 221 | "variables": { 222 | "source_path": { 223 | "description": "Source path on host", 224 | "format": "filepath", 225 | "is_required": true 226 | }, 227 | "target_path": { 228 | "description": "Path to mount in the container. It should be rooted in `/project` directory.", 229 | "is_required": true, 230 | "default": "/project", 231 | } 232 | } 233 | } 234 | ], 235 | "package_arguments": [ 236 | { 237 | "type": "positional", 238 | "value_hint": "target_dir", 239 | "value": "/project", 240 | } 241 | ] 242 | } 243 | ] 244 | } 245 | ``` 246 | 247 | claude_desktop_config.json: 248 | ```json 249 | { 250 | "filesystem": { 251 | "server": "@modelcontextprotocol/servers/src/filesystem@1.0.2", 252 | "package": "docker", 253 | "settings": { 254 | "--mount": [ 255 | { "source_path": "/Users/username/Desktop", "target_path": "/project/desktop" }, 256 | { "source_path": "/path/to/other/allowed/dir", "target_path": "/project/other/allowed/dir,ro" }, 257 | ] 258 | } 259 | } 260 | } 261 | ``` 262 | 263 | #### Remote Server 264 | 265 | API Response: 266 | ```json 267 | { 268 | "id": "remote-fs-54321", 269 | "name": "Remote Brave Search Server", 270 | "description": "Cloud-hosted MCP Brave Search server", 271 | "repository": { 272 | "url": "https://github.com/example/remote-fs", 273 | "source": "github", 274 | "id": "xyz789ab-cdef-0123-4567-890ghijklmno" 275 | }, 276 | "version_detail": { 277 | "version": "1.0.2", 278 | "release_date": "2023-06-15T10:30:00Z", 279 | "is_latest": true 280 | }, 281 | "remotes": [ 282 | { 283 | "transport_type": "sse", 284 | "url": "https://mcp-fs.example.com/sse" 285 | } 286 | ] 287 | } 288 | ``` 289 | -------------------------------------------------------------------------------- /docs/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: MCP Server Registry API 4 | summary: API for discovering and accessing MCP servers metadata 5 | description: | 6 | REST API that centralizes metadata about publicly available MCP servers by allowing server creators to submit 7 | and maintain metadata about their servers in a standardized format. This API enables MCP client 8 | applications and "server aggregator" type consumers to discover and install MCP servers. 9 | version: 0.0.1 10 | contact: 11 | name: MCP Community Working Group 12 | license: 13 | name: MIT 14 | identifier: MIT 15 | servers: 16 | # TODO: Still think a unique name would be better; maybe we open a public discussion on the topic and let people submit ideas? 17 | - url: https://registry.modelcontextprotocol.io 18 | description: MCP Server Registry 19 | # TODO: Webhooks here would be interesting, but out of scope for MVP 20 | 21 | paths: 22 | /v0/servers: 23 | get: 24 | summary: List MCP servers 25 | description: Returns a list of all registered MCP servers 26 | parameters: 27 | - name: limit 28 | in: query 29 | description: Number of results per page (maximum 5000) 30 | schema: 31 | type: integer 32 | default: 5000 33 | maximum: 5000 34 | minimum: 1 35 | - name: offset 36 | in: query 37 | description: Number of results to skip for pagination 38 | schema: 39 | type: integer 40 | default: 0 41 | minimum: 0 42 | responses: 43 | '200': 44 | description: A list of MCP servers 45 | content: 46 | application/json: 47 | schema: 48 | $ref: '#/components/schemas/ServerList' 49 | /v0/servers/{id}: 50 | get: 51 | summary: Get MCP server details 52 | description: Returns detailed information about a specific MCP server 53 | parameters: 54 | - name: id 55 | in: path 56 | required: true 57 | description: Unique ID of the server 58 | schema: 59 | type: string 60 | format: uuid 61 | - name: version 62 | in: query 63 | description: Desired MCP server version 64 | schema: 65 | type: string 66 | responses: 67 | '200': 68 | description: Detailed server information 69 | content: 70 | application/json: 71 | schema: 72 | $ref: '#/components/schemas/ServerDetail' 73 | '404': 74 | description: Server not found 75 | content: 76 | application/json: 77 | schema: 78 | type: object 79 | properties: 80 | error: 81 | type: string 82 | example: "Server not found" 83 | components: 84 | schemas: 85 | jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema" 86 | Repository: 87 | type: object 88 | required: 89 | - url 90 | - source 91 | - id 92 | properties: 93 | url: 94 | type: string 95 | format: uri 96 | example: "https://github.com/modelcontextprotocol/servers" 97 | source: 98 | type: string 99 | enum: [github, gitlab] # TODO: Add all supported sources as a whitelist 100 | example: "github" 101 | id: 102 | type: string 103 | example: "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" 104 | 105 | Server: 106 | type: object 107 | required: 108 | - id 109 | - name 110 | - description 111 | - version_detail 112 | properties: 113 | id: 114 | type: string 115 | format: uuid 116 | example: "a5e8a7f0-d4e4-4a1d-b12f-2896a23fd4f1" 117 | name: 118 | type: string 119 | example: "@modelcontextprotocol/servers/src/filesystem" 120 | description: 121 | type: string 122 | example: "Node.js server implementing Model Context Protocol (MCP) for filesystem operations." 123 | repository: 124 | $ref: '#/components/schemas/Repository' 125 | version_detail: 126 | type: object 127 | required: 128 | - version 129 | - release_date 130 | - is_latest 131 | properties: 132 | version: 133 | type: string 134 | example: "1.0.2" 135 | description: Equivalent of Implementation.version in MCP specification. 136 | release_date: 137 | type: string 138 | format: date-time 139 | example: "2023-06-15T10:30:00Z" 140 | description: Datetime that the MCP server version was published to the registry. 141 | is_latest: 142 | type: boolean 143 | example: true 144 | description: Whether the MCP server version is the latest version available in the registry. 145 | $schema: "https://json-schema.org/draft/2020-12/schema" 146 | 147 | ServerList: 148 | type: object 149 | required: 150 | - servers 151 | - total_count 152 | properties: 153 | servers: 154 | type: array 155 | items: 156 | $ref: '#/components/schemas/Server' 157 | next: 158 | type: string 159 | format: uri 160 | example: "https://registry.modelcontextprotocol.io/servers?offset=50" 161 | total_count: 162 | type: integer 163 | example: 1 164 | 165 | Package: 166 | type: object 167 | required: 168 | - registry_name 169 | - name 170 | - version 171 | properties: 172 | registry_name: 173 | type: string 174 | enum: [npm, docker, pypi, homebrew] 175 | example: "npm" 176 | name: 177 | type: string 178 | example: "io.modelcontextprotocol/filesystem" 179 | version: 180 | type: string 181 | example: "1.0.2" 182 | runtime_hint: 183 | type: string 184 | description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtime_arguments` are present. 185 | examples: [npx, uvx] 186 | runtime_arguments: 187 | type: array 188 | description: A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtime_hint` field should be provided when `runtime_arguments` are present. 189 | items: 190 | $ref: '#/components/schemas/Argument' 191 | package_arguments: 192 | type: array 193 | description: A list of arguments to be passed to the package's binary. 194 | items: 195 | $ref: '#/components/schemas/Argument' 196 | environment_variables: 197 | type: array 198 | description: A mapping of environment variables to be set when running the package. 199 | items: 200 | $ref: '#/components/schemas/KeyValueInput' 201 | 202 | Input: 203 | type: object 204 | properties: 205 | description: 206 | description: A description of the input, which clients can use to provide context to the user. 207 | type: string 208 | is_required: 209 | type: boolean 210 | default: false 211 | format: 212 | type: string 213 | description: | 214 | Specifies the input format. Supported values include `filepath`, which should be interpreted as a file on the user's filesystem. 215 | 216 | When the input is converted to a string, booleans should be represented by the strings "true" and "false", and numbers should be represented as decimal values. 217 | enum: [string, number, boolean, filepath] 218 | default: string 219 | value: 220 | type: string 221 | description: | 222 | The default value for the input. If this is not set, the user may be prompted to provide a value. 223 | 224 | Identifiers wrapped in `{curly_braces}` will be replaced with the corresponding properties from the input `variables` map. If an identifier in braces is not found in `variables`, or if `variables` is not provided, the `{curly_braces}` substring should remain unchanged. 225 | is_secret: 226 | type: boolean 227 | description: Indicates whether the input is a secret value (e.g., password, token). If true, clients should handle the value securely. 228 | default: false 229 | default: 230 | type: string 231 | description: The default value for the input. 232 | choices: 233 | type: array 234 | description: A list of possible values for the input. If provided, the user must select one of these values. 235 | items: 236 | type: string 237 | example: [] 238 | 239 | InputWithVariables: 240 | allOf: 241 | - $ref: '#/components/schemas/Input' 242 | - type: object 243 | properties: 244 | variables: 245 | type: object 246 | description: A map of variable names to their values. Keys in the input `value` that are wrapped in `{curly_braces}` will be replaced with the corresponding variable values. 247 | additionalProperties: 248 | $ref: '#/components/schemas/Input' 249 | 250 | PositionalArgument: 251 | description: A positional input is a value inserted verbatim into the command line. 252 | allOf: 253 | - $ref: '#/components/schemas/InputWithVariables' 254 | - type: object 255 | required: 256 | - type 257 | - value_hint 258 | properties: 259 | type: 260 | type: string 261 | enum: [positional] 262 | example: "positional" 263 | value_hint: 264 | type: string 265 | description: An identifier-like hint for the value. This is not part of the command line, but can be used by client configuration and to provide hints to users. 266 | example: file_path 267 | is_repeated: 268 | type: boolean 269 | description: Whether the argument can be repeated multiple times in the command line. 270 | default: false 271 | 272 | NamedArgument: 273 | description: A command-line `--flag={value}`. 274 | allOf: 275 | - $ref: '#/components/schemas/InputWithVariables' 276 | - type: object 277 | required: 278 | - type 279 | - name 280 | properties: 281 | type: 282 | type: string 283 | enum: [named] 284 | example: "named" 285 | name: 286 | type: string 287 | description: The flag name, including any leading dashes. 288 | example: "--port" 289 | is_repeated: 290 | type: boolean 291 | description: Whether the argument can be repeated multiple times. 292 | default: false 293 | 294 | KeyValueInput: 295 | allOf: 296 | - $ref: '#/components/schemas/InputWithVariables' 297 | - type: object 298 | required: 299 | - name 300 | properties: 301 | name: 302 | type: string 303 | description: Name of the header or environment variable. 304 | example: SOME_VARIABLE 305 | 306 | Argument: 307 | anyOf: 308 | - $ref: '#/components/schemas/PositionalArgument' 309 | - $ref: '#/components/schemas/NamedArgument' 310 | 311 | Remote: 312 | type: object 313 | required: 314 | - transport_type 315 | - url 316 | properties: 317 | transport_type: 318 | type: string 319 | enum: [streamable, sse] 320 | example: "sse" 321 | url: 322 | type: string 323 | format: uri 324 | example: "https://mcp-fs.example.com/sse" 325 | headers: 326 | type: array 327 | items: 328 | $ref: '#/components/schemas/KeyValueInput' 329 | 330 | ServerDetail: 331 | allOf: 332 | - $ref: '#/components/schemas/Server' 333 | - type: object 334 | properties: 335 | packages: 336 | type: array 337 | items: 338 | $ref: '#/components/schemas/Package' 339 | remotes: 340 | type: array 341 | items: 342 | $ref: '#/components/schemas/Remote' 343 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/modelcontextprotocol/registry 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/caarlos0/env/v11 v11.3.1 7 | github.com/google/uuid v1.6.0 8 | github.com/stretchr/testify v1.10.0 9 | github.com/swaggo/files v1.0.1 10 | github.com/swaggo/http-swagger v1.3.4 11 | go.mongodb.org/mongo-driver v1.17.3 12 | golang.org/x/net v0.39.0 13 | ) 14 | 15 | require ( 16 | github.com/KyleBanks/depth v1.2.1 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 19 | github.com/go-openapi/jsonreference v0.21.0 // indirect 20 | github.com/go-openapi/spec v0.21.0 // indirect 21 | github.com/go-openapi/swag v0.23.1 // indirect 22 | github.com/golang/snappy v0.0.4 // indirect 23 | github.com/josharian/intern v1.0.0 // indirect 24 | github.com/klauspost/compress v1.16.7 // indirect 25 | github.com/mailru/easyjson v0.9.0 // indirect 26 | github.com/montanaflynn/stats v0.7.1 // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | github.com/stretchr/objx v0.5.2 // indirect 29 | github.com/swaggo/swag v1.16.4 // indirect 30 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 31 | github.com/xdg-go/scram v1.1.2 // indirect 32 | github.com/xdg-go/stringprep v1.0.4 // indirect 33 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 34 | golang.org/x/crypto v0.37.0 // indirect 35 | golang.org/x/sync v0.13.0 // indirect 36 | golang.org/x/text v0.24.0 // indirect 37 | golang.org/x/tools v0.32.0 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | 41 | // temporary replace directive to use local version of the module so we can share in different orgs 42 | replace github.com/modelcontextprotocol/registry => ./ 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 2 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 3 | github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= 4 | github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= 8 | github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= 9 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 10 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 11 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 12 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 13 | github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= 14 | github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= 15 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 16 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 20 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 22 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 23 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 24 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 25 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 26 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 27 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 28 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 29 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 30 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 31 | github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= 32 | github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 36 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 37 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 38 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 39 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 40 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 41 | github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= 42 | github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= 43 | github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= 44 | github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= 45 | github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= 46 | github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= 47 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 48 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 49 | github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= 50 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 51 | github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= 52 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 53 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 54 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 55 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 56 | go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= 57 | go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 58 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 59 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 60 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 61 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 62 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 63 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 64 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 65 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 66 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 67 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 68 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 69 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 70 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 71 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 72 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 73 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 74 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 75 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 76 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 82 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 83 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 84 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 85 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 86 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 87 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 88 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 89 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 90 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 91 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 92 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 93 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 94 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 95 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 96 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 97 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 98 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 99 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 100 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 101 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 102 | -------------------------------------------------------------------------------- /integrationtests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory contains integration tests for the MCP Registry API using the fake service implementation. 4 | 5 | ## Overview 6 | 7 | The integration tests are designed to test the complete flow of the publish endpoint using real service implementations (fake service) rather than mocks. This provides confidence that the entire request/response cycle works correctly. 8 | 9 | ## Test Structure 10 | 11 | ### `publish_integration_test.go` 12 | 13 | Contains comprehensive integration tests for the publish endpoint: 14 | 15 | - **TestPublishIntegration**: Tests various scenarios for publishing servers 16 | - Successful publish with GitHub authentication 17 | - Successful publish without authentication (for non-GitHub servers) 18 | - Error cases: missing name, missing version, missing auth header, invalid JSON, unsupported HTTP methods 19 | - Duplicate package handling: fails when same name+version, succeeds with different versions 20 | 21 | - **TestPublishIntegrationWithComplexPackages**: Tests publishing servers with complex package configurations 22 | - Multiple runtime arguments (named and positional) 23 | - Package arguments 24 | - Environment variables (including secrets) 25 | - Multiple remotes with different transport types 26 | - Headers for HTTP remotes 27 | 28 | - **TestPublishIntegrationEndToEnd**: Tests the complete end-to-end flow 29 | - Publishes a server and verifies it can be retrieved 30 | - Checks that the server appears in the registry list 31 | - Verifies count consistency 32 | 33 | ## Mock Services 34 | 35 | ### MockAuthService 36 | 37 | A simple mock implementation of the `auth.Service` interface that: 38 | - Accepts any non-empty token for GitHub authentication 39 | - Always allows authentication for `AuthMethodNone` 40 | - Provides realistic responses for auth flow methods 41 | 42 | ## Running the Tests 43 | 44 | From the project root directory: 45 | 46 | ```bash 47 | # Run all integration tests 48 | go test ./integrationtests/... 49 | 50 | # Run with verbose output 51 | go test -v ./integrationtests/... 52 | 53 | # Run a specific test 54 | go test -v ./integrationtests/ -run TestPublishIntegration 55 | 56 | # Run tests with race detection 57 | go test -race ./integrationtests/... 58 | 59 | # Use the convenient test runner script 60 | ./integrationtests/run_tests.sh 61 | ``` 62 | 63 | ## Test Data 64 | 65 | The tests use the fake service which comes pre-populated with sample data: 66 | - 3 sample MCP servers with different configurations 67 | - Uses in-memory database for isolation between tests 68 | - Each test creates unique server instances with UUIDs 69 | 70 | ## Benefits of Integration Tests 71 | 72 | 1. **Real Flow Testing**: Tests the actual HTTP request/response cycle 73 | 2. **Service Integration**: Validates that handlers work correctly with service implementations 74 | 3. **Data Persistence**: Verifies that published data can be retrieved 75 | 4. **Error Handling**: Tests complete error scenarios end-to-end 76 | 5. **Complex Scenarios**: Tests realistic server configurations with packages and remotes 77 | 78 | ## Dependencies 79 | 80 | These tests use: 81 | - `testify/assert` and `testify/require` for assertions 82 | - `httptest` for HTTP testing utilities 83 | - The fake service implementation for realistic data operations 84 | - Standard Go testing package 85 | 86 | ## Test Coverage 87 | 88 | The integration tests cover: 89 | - ✅ Successful publish scenarios 90 | - ✅ Authentication validation 91 | - ✅ Input validation 92 | - ✅ Duplicate package handling 93 | - ✅ Complex package configurations 94 | - ✅ Multiple remotes 95 | - ✅ Error handling 96 | - ✅ End-to-end data flow 97 | - ✅ HTTP method validation 98 | - ✅ JSON parsing errors 99 | -------------------------------------------------------------------------------- /integrationtests/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Integration Test Runner for MCP Registry 4 | # This script runs the integration tests for the publish functionality 5 | 6 | echo "Running MCP Registry Integration Tests..." 7 | echo "========================================" 8 | 9 | # Change to the project directory (parent of integrationtests) 10 | cd "$(dirname "$0")/.." 11 | 12 | # Run integration tests with verbose output 13 | echo "Running publish integration tests..." 14 | go test -v ./integrationtests/... 15 | 16 | # Check exit code 17 | if [ $? -eq 0 ]; then 18 | echo "" 19 | echo "✅ All integration tests passed!" 20 | else 21 | echo "" 22 | echo "❌ Some integration tests failed!" 23 | exit 1 24 | fi 25 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/auth.go: -------------------------------------------------------------------------------- 1 | // Package v0 contains API handlers for version 0 of the API 2 | package v0 3 | 4 | import ( 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/modelcontextprotocol/registry/internal/auth" 10 | "github.com/modelcontextprotocol/registry/internal/model" 11 | ) 12 | 13 | // StartAuthHandler handles requests to start an authentication flow 14 | func StartAuthHandler(authService auth.Service) http.HandlerFunc { 15 | return func(w http.ResponseWriter, r *http.Request) { 16 | // Only allow POST method 17 | if r.Method != http.MethodPost { 18 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 19 | return 20 | } 21 | 22 | // Read the request body 23 | body, err := io.ReadAll(r.Body) 24 | if err != nil { 25 | http.Error(w, "Error reading request body", http.StatusBadRequest) 26 | return 27 | } 28 | defer r.Body.Close() 29 | 30 | // Parse request body into AuthRequest struct 31 | var authReq struct { 32 | Method string `json:"method"` 33 | RepoRef string `json:"repo_ref"` 34 | } 35 | err = json.Unmarshal(body, &authReq) 36 | if err != nil { 37 | http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest) 38 | return 39 | } 40 | 41 | // Validate required fields 42 | if authReq.Method == "" { 43 | http.Error(w, "Auth method is required", http.StatusBadRequest) 44 | return 45 | } 46 | 47 | // Convert string method to enum type 48 | var method model.AuthMethod 49 | switch authReq.Method { 50 | case "github": 51 | method = model.AuthMethodGitHub 52 | default: 53 | http.Error(w, "Unsupported authentication method", http.StatusBadRequest) 54 | return 55 | } 56 | 57 | // Start auth flow 58 | flowInfo, statusToken, err := authService.StartAuthFlow(r.Context(), method, authReq.RepoRef) 59 | if err != nil { 60 | http.Error(w, "Failed to start auth flow: "+err.Error(), http.StatusInternalServerError) 61 | return 62 | } 63 | 64 | // Return successful response 65 | w.Header().Set("Content-Type", "application/json") 66 | w.WriteHeader(http.StatusOK) 67 | if err := json.NewEncoder(w).Encode(map[string]interface{}{ 68 | "flow_info": flowInfo, 69 | "status_token": statusToken, 70 | "expires_in": 300, // 5 minutes 71 | }); err != nil { 72 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 73 | return 74 | } 75 | } 76 | } 77 | 78 | // CheckAuthStatusHandler handles requests to check the status of an authentication flow 79 | func CheckAuthStatusHandler(authService auth.Service) http.HandlerFunc { 80 | return func(w http.ResponseWriter, r *http.Request) { 81 | // Only allow GET method 82 | if r.Method != http.MethodGet { 83 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 84 | return 85 | } 86 | 87 | // Get status token from query parameter 88 | statusToken := r.URL.Query().Get("token") 89 | if statusToken == "" { 90 | http.Error(w, "Status token is required", http.StatusBadRequest) 91 | return 92 | } 93 | 94 | // Check auth status 95 | token, err := authService.CheckAuthStatus(r.Context(), statusToken) 96 | if err != nil { 97 | if err.Error() == "pending" { 98 | // Auth is still pending 99 | w.Header().Set("Content-Type", "application/json") 100 | w.WriteHeader(http.StatusOK) 101 | if err := json.NewEncoder(w).Encode(map[string]interface{}{ 102 | "status": "pending", 103 | }); err != nil { 104 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 105 | return 106 | } 107 | return 108 | } 109 | 110 | // Other error 111 | http.Error(w, "Failed to check auth status: "+err.Error(), http.StatusInternalServerError) 112 | return 113 | } 114 | 115 | // Authentication completed successfully 116 | w.Header().Set("Content-Type", "application/json") 117 | w.WriteHeader(http.StatusOK) 118 | if err := json.NewEncoder(w).Encode(map[string]interface{}{ 119 | "status": "complete", 120 | "token": token, 121 | }); err != nil { 122 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 123 | return 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/health.go: -------------------------------------------------------------------------------- 1 | // Package v0 contains API handlers for version 0 of the API 2 | package v0 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/modelcontextprotocol/registry/internal/config" 9 | ) 10 | 11 | type HealthResponse struct { 12 | Status string `json:"status"` 13 | GitHubClientID string `json:"github_client_id"` 14 | } 15 | 16 | // HealthHandler returns a handler for health check endpoint 17 | func HealthHandler(cfg *config.Config) http.HandlerFunc { 18 | return func(w http.ResponseWriter, _ *http.Request) { 19 | w.Header().Set("Content-Type", "application/json") 20 | if err := json.NewEncoder(w).Encode(HealthResponse{ 21 | Status: "ok", 22 | GitHubClientID: cfg.GithubClientID, 23 | }); err != nil { 24 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/health_test.go: -------------------------------------------------------------------------------- 1 | package v0_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" 11 | "github.com/modelcontextprotocol/registry/internal/config" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestHealthHandler(t *testing.T) { 16 | // Test cases 17 | testCases := []struct { 18 | name string 19 | config *config.Config 20 | expectedStatus int 21 | expectedBody v0.HealthResponse 22 | }{ 23 | { 24 | name: "returns health status with github client id", 25 | config: &config.Config{ 26 | GithubClientID: "test-github-client-id", 27 | }, 28 | expectedStatus: http.StatusOK, 29 | expectedBody: v0.HealthResponse{ 30 | Status: "ok", 31 | GitHubClientID: "test-github-client-id", 32 | }, 33 | }, 34 | { 35 | name: "works with empty github client id", 36 | config: &config.Config{ 37 | GithubClientID: "", 38 | }, 39 | expectedStatus: http.StatusOK, 40 | expectedBody: v0.HealthResponse{ 41 | Status: "ok", 42 | GitHubClientID: "", 43 | }, 44 | }, 45 | } 46 | 47 | for _, tc := range testCases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | // Create handler with the test config 50 | handler := v0.HealthHandler(tc.config) 51 | 52 | // Create request 53 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | // Create response recorder 59 | rr := httptest.NewRecorder() 60 | 61 | // Call the handler 62 | handler.ServeHTTP(rr, req) 63 | 64 | // Check status code 65 | assert.Equal(t, tc.expectedStatus, rr.Code) 66 | 67 | // Check content type 68 | assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) 69 | 70 | // Parse response body 71 | var resp v0.HealthResponse 72 | err = json.NewDecoder(rr.Body).Decode(&resp) 73 | assert.NoError(t, err) 74 | 75 | // Check the response body 76 | assert.Equal(t, tc.expectedBody, resp) 77 | }) 78 | } 79 | } 80 | 81 | // TestHealthHandlerIntegration tests the handler with actual HTTP requests 82 | func TestHealthHandlerIntegration(t *testing.T) { 83 | // Create test server 84 | cfg := &config.Config{ 85 | GithubClientID: "integration-test-client-id", 86 | } 87 | 88 | server := httptest.NewServer(v0.HealthHandler(cfg)) 89 | defer server.Close() 90 | 91 | // Send request to the test server 92 | ctx := context.Background() 93 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) 94 | if err != nil { 95 | t.Fatalf("Failed to create request: %v", err) 96 | } 97 | 98 | client := &http.Client{} 99 | resp, err := client.Do(req) 100 | if err != nil { 101 | t.Fatalf("Failed to send request: %v", err) 102 | } 103 | defer resp.Body.Close() 104 | 105 | // Check status code 106 | assert.Equal(t, http.StatusOK, resp.StatusCode) 107 | 108 | // Check content type 109 | assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) 110 | 111 | // Parse response body 112 | var healthResp v0.HealthResponse 113 | err = json.NewDecoder(resp.Body).Decode(&healthResp) 114 | assert.NoError(t, err) 115 | 116 | // Check the response body 117 | expectedResp := v0.HealthResponse{ 118 | Status: "ok", 119 | GitHubClientID: "integration-test-client-id", 120 | } 121 | assert.Equal(t, expectedResp, healthResp) 122 | } 123 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/ping.go: -------------------------------------------------------------------------------- 1 | // Package v0 contains API handlers for version 0 of the API 2 | package v0 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/modelcontextprotocol/registry/internal/config" 9 | ) 10 | 11 | // PingHandler returns a handler for the ping endpoint that returns build version 12 | func PingHandler(cfg *config.Config) http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | if r.Method != http.MethodGet { 15 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 16 | return 17 | } 18 | 19 | response := map[string]string{ 20 | "status": "ok", 21 | "version": cfg.Version, 22 | } 23 | 24 | w.Header().Set("Content-Type", "application/json") 25 | if err := json.NewEncoder(w).Encode(response); err != nil { 26 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/publish.go: -------------------------------------------------------------------------------- 1 | // Package v0 contains API handlers for version 0 of the API 2 | package v0 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/modelcontextprotocol/registry/internal/auth" 12 | "github.com/modelcontextprotocol/registry/internal/database" 13 | "github.com/modelcontextprotocol/registry/internal/model" 14 | "github.com/modelcontextprotocol/registry/internal/service" 15 | "golang.org/x/net/html" 16 | ) 17 | 18 | // PublishHandler handles requests to publish new server details to the registry 19 | func PublishHandler(registry service.RegistryService, authService auth.Service) http.HandlerFunc { 20 | return func(w http.ResponseWriter, r *http.Request) { 21 | // Only allow POST method 22 | if r.Method != http.MethodPost { 23 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 24 | return 25 | } 26 | 27 | // Read the request body 28 | body, err := io.ReadAll(r.Body) 29 | if err != nil { 30 | http.Error(w, "Error reading request body", http.StatusBadRequest) 31 | return 32 | } 33 | defer r.Body.Close() 34 | 35 | // Parse request body into PublishRequest struct 36 | var publishReq model.PublishRequest 37 | err = json.Unmarshal(body, &publishReq) 38 | if err != nil { 39 | http.Error(w, "Invalid request payload: "+err.Error(), http.StatusBadRequest) 40 | return 41 | } 42 | 43 | // Get server details from the request 44 | var serverDetail model.ServerDetail 45 | 46 | err = json.Unmarshal(body, &serverDetail) 47 | if err != nil { 48 | http.Error(w, "Invalid server detail payload: "+err.Error(), http.StatusBadRequest) 49 | return 50 | } 51 | // Validate required fields 52 | if serverDetail.Name == "" { 53 | http.Error(w, "Name is required", http.StatusBadRequest) 54 | return 55 | } 56 | 57 | // Version is required 58 | if serverDetail.VersionDetail.Version == "" { 59 | http.Error(w, "Version is required", http.StatusBadRequest) 60 | return 61 | } 62 | 63 | // Get auth token from Authorization header 64 | authHeader := r.Header.Get("Authorization") 65 | if authHeader == "" { 66 | http.Error(w, "Authorization header is required", http.StatusUnauthorized) 67 | return 68 | } 69 | 70 | // Handle bearer token format (e.g., "Bearer xyz123") 71 | token := authHeader 72 | if len(authHeader) > 7 && strings.ToUpper(authHeader[:7]) == "BEARER " { 73 | token = authHeader[7:] 74 | } 75 | 76 | // Determine authentication method based on server name prefix 77 | var authMethod model.AuthMethod 78 | switch { 79 | case strings.HasPrefix(serverDetail.Name, "io.github"): 80 | authMethod = model.AuthMethodGitHub 81 | // Additional cases can be added here for other prefixes 82 | default: 83 | // Keep the default auth method as AuthMethodNone 84 | authMethod = model.AuthMethodNone 85 | } 86 | 87 | serverName := html.EscapeString(serverDetail.Name) 88 | 89 | // Setup authentication info 90 | a := model.Authentication{ 91 | Method: authMethod, 92 | Token: token, 93 | RepoRef: serverName, 94 | } 95 | 96 | valid, err := authService.ValidateAuth(r.Context(), a) 97 | if err != nil { 98 | if errors.Is(err, auth.ErrAuthRequired) { 99 | http.Error(w, "Authentication is required for publishing", http.StatusUnauthorized) 100 | return 101 | } 102 | http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized) 103 | return 104 | } 105 | 106 | if !valid { 107 | http.Error(w, "Invalid authentication credentials", http.StatusUnauthorized) 108 | return 109 | } 110 | 111 | // Call the publish method on the registry service 112 | err = registry.Publish(&serverDetail) 113 | if err != nil { 114 | // Check for specific error types and return appropriate HTTP status codes 115 | if errors.Is(err, database.ErrInvalidVersion) || errors.Is(err, database.ErrAlreadyExists) { 116 | http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusBadRequest) 117 | return 118 | } 119 | http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusInternalServerError) 120 | return 121 | } 122 | 123 | w.Header().Set("Content-Type", "application/json") 124 | w.WriteHeader(http.StatusCreated) 125 | if err := json.NewEncoder(w).Encode(map[string]string{ 126 | "message": "Server publication successful", 127 | "id": serverDetail.ID, 128 | }); err != nil { 129 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 130 | return 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/publish_test.go: -------------------------------------------------------------------------------- 1 | package v0_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" 12 | "github.com/modelcontextprotocol/registry/internal/auth" 13 | "github.com/modelcontextprotocol/registry/internal/model" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/mock" 16 | ) 17 | 18 | // MockRegistryService is a mock implementation of the RegistryService interface 19 | type MockRegistryService struct { 20 | mock.Mock 21 | } 22 | 23 | func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) { 24 | args := m.Mock.Called(cursor, limit) 25 | return args.Get(0).([]model.Server), args.String(1), args.Error(2) 26 | } 27 | 28 | func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) { 29 | args := m.Mock.Called(id) 30 | return args.Get(0).(*model.ServerDetail), args.Error(1) 31 | } 32 | 33 | func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error { 34 | args := m.Mock.Called(serverDetail) 35 | return args.Error(0) 36 | } 37 | 38 | // MockAuthService is a mock implementation of the auth.Service interface 39 | type MockAuthService struct { 40 | mock.Mock 41 | } 42 | 43 | func (m *MockAuthService) StartAuthFlow( 44 | ctx context.Context, method model.AuthMethod, repoRef string, 45 | ) (map[string]string, string, error) { 46 | args := m.Mock.Called(ctx, method, repoRef) 47 | return args.Get(0).(map[string]string), args.String(1), args.Error(2) 48 | } 49 | 50 | func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) { 51 | args := m.Mock.Called(ctx, statusToken) 52 | return args.String(0), args.Error(1) 53 | } 54 | 55 | func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) { 56 | args := m.Mock.Called(ctx, authentication) 57 | return args.Bool(0), args.Error(1) 58 | } 59 | 60 | func TestPublishHandler(t *testing.T) { 61 | testCases := []struct { 62 | name string 63 | method string 64 | requestBody interface{} 65 | authHeader string 66 | setupMocks func(*MockRegistryService, *MockAuthService) 67 | expectedStatus int 68 | expectedResponse map[string]string 69 | expectedError string 70 | }{ 71 | { 72 | name: "successful publish with GitHub auth", 73 | method: http.MethodPost, 74 | requestBody: model.ServerDetail{ 75 | Server: model.Server{ 76 | ID: "test-id", 77 | Name: "io.github.example/test-server", 78 | Description: "A test server", 79 | Repository: model.Repository{ 80 | URL: "https://github.com/example/test-server", 81 | Source: "github", 82 | ID: "example/test-server", 83 | }, 84 | VersionDetail: model.VersionDetail{ 85 | Version: "1.0.0", 86 | ReleaseDate: "2025-05-25T00:00:00Z", 87 | IsLatest: true, 88 | }, 89 | }, 90 | }, 91 | authHeader: "Bearer github_token_123", 92 | setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { 93 | authSvc.Mock.On("ValidateAuth", mock.Anything, model.Authentication{ 94 | Method: model.AuthMethodGitHub, 95 | Token: "github_token_123", 96 | RepoRef: "io.github.example/test-server", 97 | }).Return(true, nil) 98 | registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) 99 | }, 100 | expectedStatus: http.StatusCreated, 101 | expectedResponse: map[string]string{ 102 | "message": "Server publication successful", 103 | "id": "test-id", 104 | }, 105 | }, 106 | { 107 | name: "successful publish with no auth (AuthMethodNone)", 108 | method: http.MethodPost, 109 | requestBody: model.ServerDetail{ 110 | Server: model.Server{ 111 | ID: "test-id-2", 112 | Name: "example/test-server", 113 | Description: "A test server without auth", 114 | Repository: model.Repository{ 115 | URL: "https://example.com/test-server", 116 | Source: "example", 117 | ID: "example/test-server", 118 | }, 119 | VersionDetail: model.VersionDetail{ 120 | Version: "1.0.0", 121 | ReleaseDate: "2025-05-25T00:00:00Z", 122 | IsLatest: true, 123 | }, 124 | }, 125 | }, 126 | authHeader: "Bearer some_token", 127 | setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { 128 | authSvc.Mock.On("ValidateAuth", mock.Anything, model.Authentication{ 129 | Method: model.AuthMethodNone, 130 | Token: "some_token", 131 | RepoRef: "example/test-server", 132 | }).Return(true, nil) 133 | registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) 134 | }, 135 | expectedStatus: http.StatusCreated, 136 | expectedResponse: map[string]string{ 137 | "message": "Server publication successful", 138 | "id": "test-id-2", 139 | }, 140 | }, 141 | { 142 | name: "method not allowed", 143 | method: http.MethodGet, 144 | requestBody: nil, 145 | authHeader: "", 146 | setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, 147 | expectedStatus: http.StatusMethodNotAllowed, 148 | expectedError: "Method not allowed", 149 | }, 150 | { 151 | name: "missing request body", 152 | method: http.MethodPost, 153 | requestBody: "", 154 | authHeader: "", 155 | setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, 156 | expectedStatus: http.StatusBadRequest, 157 | expectedError: "Invalid request payload:", 158 | }, 159 | { 160 | name: "missing server name", 161 | method: http.MethodPost, 162 | requestBody: model.ServerDetail{ 163 | Server: model.Server{ 164 | ID: "test-id", 165 | Name: "", // Missing name 166 | Description: "A test server", 167 | VersionDetail: model.VersionDetail{ 168 | Version: "1.0.0", 169 | ReleaseDate: "2025-05-25T00:00:00Z", 170 | IsLatest: true, 171 | }, 172 | }, 173 | }, 174 | authHeader: "", 175 | setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, 176 | expectedStatus: http.StatusBadRequest, 177 | expectedError: "Name is required", 178 | }, 179 | { 180 | name: "missing version", 181 | method: http.MethodPost, 182 | requestBody: model.ServerDetail{ 183 | Server: model.Server{ 184 | ID: "test-id", 185 | Name: "test-server", 186 | Description: "A test server", 187 | VersionDetail: model.VersionDetail{ 188 | Version: "", // Missing version 189 | ReleaseDate: "2025-05-25T00:00:00Z", 190 | IsLatest: true, 191 | }, 192 | }, 193 | }, 194 | authHeader: "", 195 | setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, 196 | expectedStatus: http.StatusBadRequest, 197 | expectedError: "Version is required", 198 | }, 199 | { 200 | name: "missing authorization header", 201 | method: http.MethodPost, 202 | requestBody: model.ServerDetail{ 203 | Server: model.Server{ 204 | ID: "test-id", 205 | Name: "test-server", 206 | Description: "A test server", 207 | VersionDetail: model.VersionDetail{ 208 | Version: "1.0.0", 209 | ReleaseDate: "2025-05-25T00:00:00Z", 210 | IsLatest: true, 211 | }, 212 | }, 213 | }, 214 | authHeader: "", // Missing auth header 215 | setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {}, 216 | expectedStatus: http.StatusUnauthorized, 217 | expectedError: "Authorization header is required", 218 | }, 219 | { 220 | name: "authentication required error", 221 | method: http.MethodPost, 222 | requestBody: model.ServerDetail{ 223 | Server: model.Server{ 224 | ID: "test-id", 225 | Name: "test-server", 226 | Description: "A test server", 227 | VersionDetail: model.VersionDetail{ 228 | Version: "1.0.0", 229 | ReleaseDate: "2025-05-25T00:00:00Z", 230 | IsLatest: true, 231 | }, 232 | }, 233 | }, 234 | authHeader: "Bearer token", 235 | setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) { 236 | authSvc.Mock.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, auth.ErrAuthRequired) 237 | }, 238 | expectedStatus: http.StatusUnauthorized, 239 | expectedError: "Authentication is required for publishing", 240 | }, 241 | { 242 | name: "authentication failed", 243 | method: http.MethodPost, 244 | requestBody: model.ServerDetail{ 245 | Server: model.Server{ 246 | ID: "test-id", 247 | Name: "test-server", 248 | Description: "A test server", 249 | VersionDetail: model.VersionDetail{ 250 | Version: "1.0.0", 251 | ReleaseDate: "2025-05-25T00:00:00Z", 252 | IsLatest: true, 253 | }, 254 | }, 255 | }, 256 | authHeader: "Bearer invalid_token", 257 | setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) { 258 | authSvc.Mock.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, nil) 259 | }, 260 | expectedStatus: http.StatusUnauthorized, 261 | expectedError: "Invalid authentication credentials", 262 | }, 263 | { 264 | name: "registry service error", 265 | method: http.MethodPost, 266 | requestBody: model.ServerDetail{ 267 | Server: model.Server{ 268 | ID: "test-id", 269 | Name: "test-server", 270 | Description: "A test server", 271 | VersionDetail: model.VersionDetail{ 272 | Version: "1.0.0", 273 | ReleaseDate: "2025-05-25T00:00:00Z", 274 | IsLatest: true, 275 | }, 276 | }, 277 | }, 278 | authHeader: "Bearer token", 279 | setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { 280 | authSvc.Mock.On("ValidateAuth", mock.Anything, mock.Anything).Return(true, nil) 281 | registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(assert.AnError) 282 | }, 283 | expectedStatus: http.StatusInternalServerError, 284 | expectedError: "Failed to publish server details:", 285 | }, 286 | { 287 | name: "HTML injection attack in name field", 288 | method: http.MethodPost, 289 | requestBody: model.ServerDetail{ 290 | Server: model.Server{ 291 | ID: "test-id-html", 292 | Name: "io.github.malicious/test-server", 293 | Description: "A test server with HTML injection attempt", 294 | Repository: model.Repository{ 295 | URL: "https://github.com/malicious/test-server", 296 | Source: "github", 297 | ID: "malicious/test-server", 298 | }, 299 | VersionDetail: model.VersionDetail{ 300 | Version: "1.0.0", 301 | ReleaseDate: "2025-05-25T00:00:00Z", 302 | IsLatest: true, 303 | }, 304 | }, 305 | }, 306 | authHeader: "Bearer github_token_123", 307 | setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { 308 | // The auth service should receive the escaped HTML version of the name 309 | authSvc.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { 310 | // Verify that the RepoRef contains escaped HTML, not the raw script tag 311 | return auth.Method == model.AuthMethodGitHub && 312 | auth.Token == "github_token_123" && 313 | auth.RepoRef == "io.github.malicious/<script>alert('XSS')</script>test-server" 314 | })).Return(true, nil) 315 | registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) 316 | }, 317 | expectedStatus: http.StatusCreated, 318 | expectedResponse: map[string]string{ 319 | "message": "Server publication successful", 320 | "id": "test-id-html", 321 | }, 322 | }, 323 | { 324 | name: "HTML injection attack in name field with non-GitHub prefix", 325 | method: http.MethodPost, 326 | requestBody: model.ServerDetail{ 327 | Server: model.Server{ 328 | ID: "test-id-html-non-github", 329 | Name: "malicious.com/test-server", 330 | Description: "A test server with HTML injection attempt (non-GitHub)", 331 | Repository: model.Repository{ 332 | URL: "https://malicious.com/test-server", 333 | Source: "custom", 334 | ID: "malicious/test-server", 335 | }, 336 | VersionDetail: model.VersionDetail{ 337 | Version: "1.0.0", 338 | ReleaseDate: "2025-05-25T00:00:00Z", 339 | IsLatest: true, 340 | }, 341 | }, 342 | }, 343 | authHeader: "Bearer some_token", 344 | setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { 345 | // The auth service should receive the escaped HTML version of the name with AuthMethodNone 346 | authSvc.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { 347 | // Verify that the RepoRef contains escaped HTML, not the raw script tag 348 | return auth.Method == model.AuthMethodNone && 349 | auth.Token == "some_token" && 350 | auth.RepoRef == "malicious.com/<script>alert('XSS')</script>test-server" 351 | })).Return(true, nil) 352 | registry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) 353 | }, 354 | expectedStatus: http.StatusCreated, 355 | expectedResponse: map[string]string{ 356 | "message": "Server publication successful", 357 | "id": "test-id-html-non-github", 358 | }, 359 | }, 360 | } 361 | 362 | for _, tc := range testCases { 363 | t.Run(tc.name, func(t *testing.T) { 364 | // Create mocks 365 | mockRegistry := new(MockRegistryService) 366 | mockAuthService := new(MockAuthService) 367 | 368 | // Setup mocks 369 | tc.setupMocks(mockRegistry, mockAuthService) 370 | 371 | // Create handler 372 | handler := v0.PublishHandler(mockRegistry, mockAuthService) 373 | 374 | // Prepare request body 375 | var requestBody []byte 376 | if tc.requestBody != nil { 377 | var err error 378 | requestBody, err = json.Marshal(tc.requestBody) 379 | assert.NoError(t, err) 380 | } 381 | 382 | // Create request 383 | req, err := http.NewRequestWithContext(context.Background(), tc.method, "/publish", bytes.NewBuffer(requestBody)) 384 | assert.NoError(t, err) 385 | 386 | // Set auth header if provided 387 | if tc.authHeader != "" { 388 | req.Header.Set("Authorization", tc.authHeader) 389 | } 390 | 391 | // Create response recorder 392 | rr := httptest.NewRecorder() 393 | 394 | // Call the handler 395 | handler.ServeHTTP(rr, req) 396 | 397 | // Check status code 398 | assert.Equal(t, tc.expectedStatus, rr.Code) 399 | 400 | if tc.expectedResponse != nil { 401 | // Check content type for successful responses 402 | assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) 403 | 404 | // Parse and verify response body 405 | var response map[string]string 406 | err = json.NewDecoder(rr.Body).Decode(&response) 407 | assert.NoError(t, err) 408 | assert.Equal(t, tc.expectedResponse, response) 409 | } 410 | 411 | if tc.expectedError != "" { 412 | // Check that the error message is contained in the response 413 | assert.Contains(t, rr.Body.String(), tc.expectedError) 414 | } 415 | 416 | // Assert that all expectations were met 417 | mockRegistry.Mock.AssertExpectations(t) 418 | mockAuthService.Mock.AssertExpectations(t) 419 | }) 420 | } 421 | } 422 | 423 | func TestPublishHandlerBearerTokenParsing(t *testing.T) { 424 | testCases := []struct { 425 | name string 426 | authHeader string 427 | expectedToken string 428 | }{ 429 | { 430 | name: "bearer token with Bearer prefix", 431 | authHeader: "Bearer github_token_123", 432 | expectedToken: "github_token_123", 433 | }, 434 | { 435 | name: "bearer token with bearer prefix (lowercase)", 436 | authHeader: "bearer github_token_123", 437 | expectedToken: "github_token_123", 438 | }, 439 | { 440 | name: "token without Bearer prefix", 441 | authHeader: "github_token_123", 442 | expectedToken: "github_token_123", 443 | }, 444 | { 445 | name: "mixed case Bearer prefix", 446 | authHeader: "BeArEr github_token_123", 447 | expectedToken: "github_token_123", 448 | }, 449 | } 450 | 451 | for _, tc := range testCases { 452 | t.Run(tc.name, func(t *testing.T) { 453 | mockRegistry := new(MockRegistryService) 454 | mockAuthService := new(MockAuthService) 455 | 456 | // Setup mock to capture the actual token passed 457 | mockAuthService.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { 458 | return auth.Token == tc.expectedToken 459 | })).Return(true, nil) 460 | mockRegistry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) 461 | 462 | handler := v0.PublishHandler(mockRegistry, mockAuthService) 463 | 464 | serverDetail := model.ServerDetail{ 465 | Server: model.Server{ 466 | ID: "test-id", 467 | Name: "test-server", 468 | Description: "A test server", 469 | VersionDetail: model.VersionDetail{ 470 | Version: "1.0.0", 471 | ReleaseDate: "2025-05-25T00:00:00Z", 472 | IsLatest: true, 473 | }, 474 | }, 475 | } 476 | 477 | requestBody, err := json.Marshal(serverDetail) 478 | assert.NoError(t, err) 479 | 480 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/publish", bytes.NewBuffer(requestBody)) 481 | assert.NoError(t, err) 482 | req.Header.Set("Authorization", tc.authHeader) 483 | 484 | rr := httptest.NewRecorder() 485 | handler.ServeHTTP(rr, req) 486 | 487 | assert.Equal(t, http.StatusCreated, rr.Code) 488 | mockAuthService.Mock.AssertExpectations(t) 489 | }) 490 | } 491 | } 492 | 493 | func TestPublishHandlerAuthMethodSelection(t *testing.T) { 494 | testCases := []struct { 495 | name string 496 | serverName string 497 | expectedAuthMethod model.AuthMethod 498 | }{ 499 | { 500 | name: "GitHub prefix triggers GitHub auth", 501 | serverName: "io.github.example/test-server", 502 | expectedAuthMethod: model.AuthMethodGitHub, 503 | }, 504 | { 505 | name: "non-GitHub prefix uses no auth", 506 | serverName: "example.com/test-server", 507 | expectedAuthMethod: model.AuthMethodNone, 508 | }, 509 | { 510 | name: "empty prefix uses no auth", 511 | serverName: "test-server", 512 | expectedAuthMethod: model.AuthMethodNone, 513 | }, 514 | } 515 | 516 | for _, tc := range testCases { 517 | t.Run(tc.name, func(t *testing.T) { 518 | mockRegistry := new(MockRegistryService) 519 | mockAuthService := new(MockAuthService) 520 | 521 | // Setup mock to capture the auth method 522 | mockAuthService.Mock.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { 523 | return auth.Method == tc.expectedAuthMethod 524 | })).Return(true, nil) 525 | mockRegistry.Mock.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) 526 | 527 | handler := v0.PublishHandler(mockRegistry, mockAuthService) 528 | 529 | serverDetail := model.ServerDetail{ 530 | Server: model.Server{ 531 | ID: "test-id", 532 | Name: tc.serverName, 533 | Description: "A test server", 534 | VersionDetail: model.VersionDetail{ 535 | Version: "1.0.0", 536 | ReleaseDate: "2025-05-25T00:00:00Z", 537 | IsLatest: true, 538 | }, 539 | }, 540 | } 541 | 542 | requestBody, err := json.Marshal(serverDetail) 543 | assert.NoError(t, err) 544 | 545 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/publish", bytes.NewBuffer(requestBody)) 546 | assert.NoError(t, err) 547 | req.Header.Set("Authorization", "Bearer test_token") 548 | 549 | rr := httptest.NewRecorder() 550 | handler.ServeHTTP(rr, req) 551 | 552 | assert.Equal(t, http.StatusCreated, rr.Code) 553 | mockAuthService.Mock.AssertExpectations(t) 554 | }) 555 | } 556 | } 557 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/servers.go: -------------------------------------------------------------------------------- 1 | // Package v0 contains API handlers for version 0 of the API 2 | package v0 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/google/uuid" 10 | "github.com/modelcontextprotocol/registry/internal/model" 11 | "github.com/modelcontextprotocol/registry/internal/service" 12 | ) 13 | 14 | // Response is a paginated API response 15 | type PaginatedResponse struct { 16 | Data []model.Server `json:"servers"` 17 | Metadata Metadata `json:"metadata,omitempty"` 18 | } 19 | 20 | // Metadata contains pagination metadata 21 | type Metadata struct { 22 | NextCursor string `json:"next_cursor,omitempty"` 23 | Count int `json:"count,omitempty"` 24 | Total int `json:"total,omitempty"` 25 | } 26 | 27 | // ServersHandler returns a handler for listing registry items 28 | func ServersHandler(registry service.RegistryService) http.HandlerFunc { 29 | return func(w http.ResponseWriter, r *http.Request) { 30 | if r.Method != http.MethodGet { 31 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 32 | return 33 | } 34 | 35 | // Parse cursor and limit from query parameters 36 | cursor := r.URL.Query().Get("cursor") 37 | if cursor != "" { 38 | _, err := uuid.Parse(cursor) 39 | if err != nil { 40 | http.Error(w, "Invalid cursor parameter", http.StatusBadRequest) 41 | return 42 | } 43 | } 44 | limitStr := r.URL.Query().Get("limit") 45 | 46 | // Default limit if not specified 47 | limit := 30 48 | 49 | // Try to parse limit from query param 50 | if limitStr != "" { 51 | parsedLimit, err := strconv.Atoi(limitStr) 52 | if err != nil { 53 | http.Error(w, "Invalid limit parameter", http.StatusBadRequest) 54 | return 55 | } 56 | 57 | // Check if limit is within reasonable bounds 58 | if parsedLimit <= 0 { 59 | http.Error(w, "Limit must be greater than 0", http.StatusBadRequest) 60 | return 61 | } 62 | 63 | if parsedLimit > 100 { 64 | // Cap maximum limit to prevent excessive queries 65 | limit = 100 66 | } else { 67 | limit = parsedLimit 68 | } 69 | } 70 | 71 | // Use the GetAll method to get paginated results 72 | registries, nextCursor, err := registry.List(cursor, limit) 73 | if err != nil { 74 | http.Error(w, err.Error(), http.StatusInternalServerError) 75 | return 76 | } 77 | 78 | // Create paginated response 79 | response := PaginatedResponse{ 80 | Data: registries, 81 | } 82 | 83 | // Add metadata if there's a next cursor 84 | if nextCursor != "" { 85 | response.Metadata = Metadata{ 86 | NextCursor: nextCursor, 87 | Count: len(registries), 88 | } 89 | } 90 | 91 | w.Header().Set("Content-Type", "application/json") 92 | if err := json.NewEncoder(w).Encode(response); err != nil { 93 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 94 | return 95 | } 96 | } 97 | } 98 | 99 | // ServersDetailHandler returns a handler for getting details of a specific server by ID 100 | func ServersDetailHandler(registry service.RegistryService) http.HandlerFunc { 101 | return func(w http.ResponseWriter, r *http.Request) { 102 | if r.Method != http.MethodGet { 103 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 104 | return 105 | } 106 | 107 | // Extract the server ID from the URL path 108 | id := r.PathValue("id") 109 | 110 | // Validate that the ID is a valid UUID 111 | _, err := uuid.Parse(id) 112 | if err != nil { 113 | http.Error(w, "Invalid server ID format", http.StatusBadRequest) 114 | return 115 | } 116 | 117 | // Get the server details from the registry service 118 | serverDetail, err := registry.GetByID(id) 119 | if err != nil { 120 | if err.Error() == "record not found" { 121 | http.Error(w, "Server not found", http.StatusNotFound) 122 | return 123 | } 124 | http.Error(w, "Error retrieving server details", http.StatusInternalServerError) 125 | return 126 | } 127 | 128 | w.Header().Set("Content-Type", "application/json") 129 | if err := json.NewEncoder(w).Encode(serverDetail); err != nil { 130 | http.Error(w, "Failed to encode response", http.StatusInternalServerError) 131 | return 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/servers_test.go: -------------------------------------------------------------------------------- 1 | package v0_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/google/uuid" 12 | v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" 13 | "github.com/modelcontextprotocol/registry/internal/model" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/mock" 16 | ) 17 | 18 | func TestServersHandler(t *testing.T) { 19 | testCases := []struct { 20 | name string 21 | method string 22 | queryParams string 23 | setupMocks func(*MockRegistryService) 24 | expectedStatus int 25 | expectedServers []model.Server 26 | expectedMeta *v0.Metadata 27 | expectedError string 28 | }{ 29 | { 30 | name: "successful list with default parameters", 31 | method: http.MethodGet, 32 | setupMocks: func(registry *MockRegistryService) { 33 | servers := []model.Server{ 34 | { 35 | ID: "550e8400-e29b-41d4-a716-446655440001", 36 | Name: "test-server-1", 37 | Description: "First test server", 38 | Repository: model.Repository{ 39 | URL: "https://github.com/example/test-server-1", 40 | Source: "github", 41 | ID: "example/test-server-1", 42 | }, 43 | VersionDetail: model.VersionDetail{ 44 | Version: "1.0.0", 45 | ReleaseDate: "2025-05-25T00:00:00Z", 46 | IsLatest: true, 47 | }, 48 | }, 49 | { 50 | ID: "550e8400-e29b-41d4-a716-446655440002", 51 | Name: "test-server-2", 52 | Description: "Second test server", 53 | Repository: model.Repository{ 54 | URL: "https://github.com/example/test-server-2", 55 | Source: "github", 56 | ID: "example/test-server-2", 57 | }, 58 | VersionDetail: model.VersionDetail{ 59 | Version: "2.0.0", 60 | ReleaseDate: "2025-05-26T00:00:00Z", 61 | IsLatest: true, 62 | }, 63 | }, 64 | } 65 | registry.Mock.On("List", "", 30).Return(servers, "", nil) 66 | }, 67 | expectedStatus: http.StatusOK, 68 | expectedServers: []model.Server{ 69 | { 70 | ID: "550e8400-e29b-41d4-a716-446655440001", 71 | Name: "test-server-1", 72 | Description: "First test server", 73 | Repository: model.Repository{ 74 | URL: "https://github.com/example/test-server-1", 75 | Source: "github", 76 | ID: "example/test-server-1", 77 | }, 78 | VersionDetail: model.VersionDetail{ 79 | Version: "1.0.0", 80 | ReleaseDate: "2025-05-25T00:00:00Z", 81 | IsLatest: true, 82 | }, 83 | }, 84 | { 85 | ID: "550e8400-e29b-41d4-a716-446655440002", 86 | Name: "test-server-2", 87 | Description: "Second test server", 88 | Repository: model.Repository{ 89 | URL: "https://github.com/example/test-server-2", 90 | Source: "github", 91 | ID: "example/test-server-2", 92 | }, 93 | VersionDetail: model.VersionDetail{ 94 | Version: "2.0.0", 95 | ReleaseDate: "2025-05-26T00:00:00Z", 96 | IsLatest: true, 97 | }, 98 | }, 99 | }, 100 | }, 101 | { 102 | name: "successful list with cursor and limit", 103 | method: http.MethodGet, 104 | queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000" + "&limit=10", 105 | setupMocks: func(registry *MockRegistryService) { 106 | servers := []model.Server{ 107 | { 108 | ID: "550e8400-e29b-41d4-a716-446655440003", 109 | Name: "test-server-3", 110 | Description: "Third test server", 111 | Repository: model.Repository{ 112 | URL: "https://github.com/example/test-server-3", 113 | Source: "github", 114 | ID: "example/test-server-3", 115 | }, 116 | VersionDetail: model.VersionDetail{ 117 | Version: "1.5.0", 118 | ReleaseDate: "2025-05-27T00:00:00Z", 119 | IsLatest: true, 120 | }, 121 | }, 122 | } 123 | nextCursor := uuid.New().String() 124 | registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil) 125 | }, 126 | expectedStatus: http.StatusOK, 127 | expectedServers: []model.Server{ 128 | { 129 | ID: "550e8400-e29b-41d4-a716-446655440003", 130 | Name: "test-server-3", 131 | Description: "Third test server", 132 | Repository: model.Repository{ 133 | URL: "https://github.com/example/test-server-3", 134 | Source: "github", 135 | ID: "example/test-server-3", 136 | }, 137 | VersionDetail: model.VersionDetail{ 138 | Version: "1.5.0", 139 | ReleaseDate: "2025-05-27T00:00:00Z", 140 | IsLatest: true, 141 | }, 142 | }, 143 | }, 144 | expectedMeta: &v0.Metadata{ 145 | NextCursor: "", // This will be dynamically set in the test 146 | Count: 1, 147 | }, 148 | }, 149 | { 150 | name: "successful list with limit capping at 100", 151 | method: http.MethodGet, 152 | queryParams: "?limit=150", 153 | setupMocks: func(registry *MockRegistryService) { 154 | servers := []model.Server{} 155 | registry.Mock.On("List", "", 100).Return(servers, "", nil) 156 | }, 157 | expectedStatus: http.StatusOK, 158 | expectedServers: []model.Server{}, 159 | }, 160 | { 161 | name: "invalid cursor parameter", 162 | method: http.MethodGet, 163 | queryParams: "?cursor=invalid-uuid", 164 | setupMocks: func(_ *MockRegistryService) {}, 165 | expectedStatus: http.StatusBadRequest, 166 | expectedError: "Invalid cursor parameter", 167 | }, 168 | { 169 | name: "invalid limit parameter - non-numeric", 170 | method: http.MethodGet, 171 | queryParams: "?limit=abc", 172 | setupMocks: func(_ *MockRegistryService) {}, 173 | expectedStatus: http.StatusBadRequest, 174 | expectedError: "Invalid limit parameter", 175 | }, 176 | { 177 | name: "invalid limit parameter - zero", 178 | method: http.MethodGet, 179 | queryParams: "?limit=0", 180 | setupMocks: func(_ *MockRegistryService) {}, 181 | expectedStatus: http.StatusBadRequest, 182 | expectedError: "Limit must be greater than 0", 183 | }, 184 | { 185 | name: "invalid limit parameter - negative", 186 | method: http.MethodGet, 187 | queryParams: "?limit=-5", 188 | setupMocks: func(_ *MockRegistryService) {}, 189 | expectedStatus: http.StatusBadRequest, 190 | expectedError: "Limit must be greater than 0", 191 | }, 192 | { 193 | name: "registry service error", 194 | method: http.MethodGet, 195 | setupMocks: func(registry *MockRegistryService) { 196 | registry.Mock.On("List", "", 30).Return([]model.Server{}, "", errors.New("database connection error")) 197 | }, 198 | expectedStatus: http.StatusInternalServerError, 199 | expectedError: "database connection error", 200 | }, 201 | { 202 | name: "method not allowed", 203 | method: http.MethodPost, 204 | setupMocks: func(_ *MockRegistryService) {}, 205 | expectedStatus: http.StatusMethodNotAllowed, 206 | expectedError: "Method not allowed", 207 | }, 208 | } 209 | 210 | for _, tc := range testCases { 211 | t.Run(tc.name, func(t *testing.T) { 212 | // Create mock registry service 213 | mockRegistry := new(MockRegistryService) 214 | tc.setupMocks(mockRegistry) 215 | 216 | // Create handler 217 | handler := v0.ServersHandler(mockRegistry) 218 | 219 | // Create request 220 | url := "/v0/servers" + tc.queryParams 221 | req, err := http.NewRequestWithContext(context.Background(), tc.method, url, nil) 222 | if err != nil { 223 | t.Fatal(err) 224 | } 225 | 226 | // Create response recorder 227 | rr := httptest.NewRecorder() 228 | 229 | // Call the handler 230 | handler.ServeHTTP(rr, req) 231 | 232 | // Check status code 233 | assert.Equal(t, tc.expectedStatus, rr.Code) 234 | 235 | if tc.expectedStatus == http.StatusOK { 236 | // Check content type 237 | assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) 238 | 239 | // Parse response body 240 | var resp v0.PaginatedResponse 241 | err = json.NewDecoder(rr.Body).Decode(&resp) 242 | assert.NoError(t, err) 243 | 244 | // Check the response data 245 | assert.Equal(t, tc.expectedServers, resp.Data) 246 | 247 | // Check metadata if expected 248 | if tc.expectedMeta != nil { 249 | assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count) 250 | if tc.expectedMeta.NextCursor != "" { 251 | assert.NotEmpty(t, resp.Metadata.NextCursor) 252 | } 253 | } 254 | } else if tc.expectedError != "" { 255 | // Check error message for non-200 responses 256 | assert.Contains(t, rr.Body.String(), tc.expectedError) 257 | } 258 | 259 | // Verify mock expectations 260 | mockRegistry.Mock.AssertExpectations(t) 261 | }) 262 | } 263 | } 264 | 265 | // TestServersHandlerIntegration tests the servers list handler with actual HTTP requests 266 | func TestServersHandlerIntegration(t *testing.T) { 267 | // Create mock registry service 268 | mockRegistry := new(MockRegistryService) 269 | 270 | servers := []model.Server{ 271 | { 272 | ID: "550e8400-e29b-41d4-a716-446655440004", 273 | Name: "integration-test-server", 274 | Description: "Integration test server", 275 | Repository: model.Repository{ 276 | URL: "https://github.com/example/integration-test", 277 | Source: "github", 278 | ID: "example/integration-test", 279 | }, 280 | VersionDetail: model.VersionDetail{ 281 | Version: "1.0.0", 282 | ReleaseDate: "2025-05-27T00:00:00Z", 283 | IsLatest: true, 284 | }, 285 | }, 286 | } 287 | 288 | mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil) 289 | 290 | // Create test server 291 | server := httptest.NewServer(v0.ServersHandler(mockRegistry)) 292 | defer server.Close() 293 | 294 | // Send request to the test server 295 | ctx := context.Background() 296 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) 297 | if err != nil { 298 | t.Fatalf("Failed to create request: %v", err) 299 | } 300 | 301 | client := &http.Client{} 302 | resp, err := client.Do(req) 303 | if err != nil { 304 | t.Fatalf("Failed to send request: %v", err) 305 | } 306 | defer resp.Body.Close() 307 | 308 | // Check status code 309 | assert.Equal(t, http.StatusOK, resp.StatusCode) 310 | 311 | // Check content type 312 | assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) 313 | 314 | // Parse response body 315 | var paginatedResp v0.PaginatedResponse 316 | err = json.NewDecoder(resp.Body).Decode(&paginatedResp) 317 | assert.NoError(t, err) 318 | 319 | // Check the response data 320 | assert.Equal(t, servers, paginatedResp.Data) 321 | assert.Empty(t, paginatedResp.Metadata.NextCursor) 322 | 323 | // Verify mock expectations 324 | mockRegistry.Mock.AssertExpectations(t) 325 | } 326 | 327 | // TestServersDetailHandlerIntegration tests the servers detail handler with actual HTTP requests 328 | func TestServersDetailHandlerIntegration(t *testing.T) { 329 | serverID := uuid.New().String() 330 | 331 | // Create mock registry service 332 | mockRegistry := new(MockRegistryService) 333 | 334 | serverDetail := &model.ServerDetail{ 335 | Server: model.Server{ 336 | ID: serverID, 337 | Name: "integration-test-server-detail", 338 | Description: "Integration test server detail", 339 | Repository: model.Repository{ 340 | URL: "https://github.com/example/integration-test-detail", 341 | Source: "github", 342 | ID: "example/integration-test-detail", 343 | }, 344 | VersionDetail: model.VersionDetail{ 345 | Version: "2.0.0", 346 | ReleaseDate: "2025-05-27T12:00:00Z", 347 | IsLatest: true, 348 | }, 349 | }, 350 | } 351 | 352 | mockRegistry.Mock.On("GetByID", serverID).Return(serverDetail, nil) 353 | 354 | // Create test server 355 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 356 | r.SetPathValue("id", serverID) 357 | v0.ServersDetailHandler(mockRegistry).ServeHTTP(w, r) 358 | })) 359 | defer server.Close() 360 | 361 | // Send request to the test server 362 | ctx := context.Background() 363 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) 364 | if err != nil { 365 | t.Fatalf("Failed to create request: %v", err) 366 | } 367 | 368 | client := &http.Client{} 369 | resp, err := client.Do(req) 370 | if err != nil { 371 | t.Fatalf("Failed to send request: %v", err) 372 | } 373 | defer resp.Body.Close() 374 | 375 | // Check status code 376 | assert.Equal(t, http.StatusOK, resp.StatusCode) 377 | 378 | // Check content type 379 | assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) 380 | 381 | // Parse response body 382 | var serverDetailResp model.ServerDetail 383 | err = json.NewDecoder(resp.Body).Decode(&serverDetailResp) 384 | assert.NoError(t, err) 385 | 386 | // Check the response data 387 | assert.Equal(t, *serverDetail, serverDetailResp) 388 | 389 | // Verify mock expectations 390 | mockRegistry.Mock.AssertExpectations(t) 391 | } 392 | -------------------------------------------------------------------------------- /internal/api/handlers/v0/swagger.go: -------------------------------------------------------------------------------- 1 | // Package v0 contains API handlers for version 0 of the API 2 | package v0 3 | 4 | import ( 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | 9 | _ "github.com/swaggo/files" // Swagger files needed for embedding 10 | httpSwagger "github.com/swaggo/http-swagger" 11 | ) 12 | 13 | // SwaggerHandler returns a handler that serves the Swagger UI 14 | func SwaggerHandler() http.HandlerFunc { 15 | return func(w http.ResponseWriter, r *http.Request) { 16 | // When accessed directly, redirect to the UI path 17 | if r.URL.Path == "/v0/swagger" { 18 | http.Redirect(w, r, "/v0/swagger/", http.StatusFound) 19 | return 20 | } 21 | 22 | // Serve the Swagger UI 23 | handler := httpSwagger.Handler( 24 | httpSwagger.URL("/v0/swagger/doc.json"), // The URL to the generated Swagger JSON 25 | httpSwagger.DeepLinking(true), 26 | ) 27 | 28 | // Handle other Swagger UI paths 29 | handler.ServeHTTP(w, r) 30 | } 31 | } 32 | 33 | // SwaggerJSONHandler serves the Swagger specification as JSON 34 | func SwaggerJSONHandler() http.HandlerFunc { 35 | return func(w http.ResponseWriter, r *http.Request) { 36 | // Find the project root directory 37 | workDir, err := os.Getwd() 38 | if err != nil { 39 | http.Error(w, "Unable to determine working directory", http.StatusInternalServerError) 40 | return 41 | } 42 | 43 | // Path to the swagger YAML file 44 | swaggerFilePath := filepath.Join(workDir, "internal", "docs", "swagger.yaml") 45 | 46 | // Serve the file directly with the correct content type 47 | http.ServeFile(w, r, swaggerFilePath) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/api/router/router.go: -------------------------------------------------------------------------------- 1 | // Package router contains API routing logic 2 | package router 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/modelcontextprotocol/registry/internal/auth" 8 | "github.com/modelcontextprotocol/registry/internal/config" 9 | "github.com/modelcontextprotocol/registry/internal/service" 10 | ) 11 | 12 | // New creates a new router with all API versions registered 13 | func New(cfg *config.Config, registry service.RegistryService, authService auth.Service) *http.ServeMux { 14 | mux := http.NewServeMux() 15 | 16 | // Register routes for all API versions 17 | RegisterV0Routes(mux, cfg, registry, authService) 18 | 19 | return mux 20 | } 21 | -------------------------------------------------------------------------------- /internal/api/router/v0.go: -------------------------------------------------------------------------------- 1 | // Package router contains API routing logic 2 | package router 3 | 4 | import ( 5 | "net/http" 6 | 7 | v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" 8 | "github.com/modelcontextprotocol/registry/internal/auth" 9 | "github.com/modelcontextprotocol/registry/internal/config" 10 | "github.com/modelcontextprotocol/registry/internal/service" 11 | ) 12 | 13 | // RegisterV0Routes registers all v0 API routes to the provided router 14 | func RegisterV0Routes( 15 | mux *http.ServeMux, cfg *config.Config, registry service.RegistryService, authService auth.Service, 16 | ) { 17 | // Register v0 endpoints 18 | mux.HandleFunc("/v0/health", v0.HealthHandler(cfg)) 19 | mux.HandleFunc("/v0/servers", v0.ServersHandler(registry)) 20 | mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry)) 21 | mux.HandleFunc("/v0/ping", v0.PingHandler(cfg)) 22 | mux.HandleFunc("/v0/publish", v0.PublishHandler(registry, authService)) 23 | 24 | // Register Swagger UI routes 25 | mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler()) 26 | mux.HandleFunc("/v0/swagger/doc.json", v0.SwaggerJSONHandler()) 27 | } 28 | -------------------------------------------------------------------------------- /internal/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/modelcontextprotocol/registry/internal/api/router" 10 | "github.com/modelcontextprotocol/registry/internal/auth" 11 | "github.com/modelcontextprotocol/registry/internal/config" 12 | "github.com/modelcontextprotocol/registry/internal/service" 13 | ) 14 | 15 | // Server represents the HTTP server 16 | type Server struct { 17 | config *config.Config 18 | registry service.RegistryService 19 | authService auth.Service 20 | router *http.ServeMux 21 | server *http.Server 22 | } 23 | 24 | // NewServer creates a new HTTP server 25 | func NewServer(cfg *config.Config, registryService service.RegistryService, authService auth.Service) *Server { 26 | // Create router with all API versions registered 27 | mux := router.New(cfg, registryService, authService) 28 | 29 | server := &Server{ 30 | config: cfg, 31 | registry: registryService, 32 | authService: authService, 33 | router: mux, 34 | server: &http.Server{ 35 | Addr: cfg.ServerAddress, 36 | Handler: mux, 37 | ReadHeaderTimeout: 10 * time.Second, 38 | }, 39 | } 40 | 41 | return server 42 | } 43 | 44 | // Start begins listening for incoming HTTP requests 45 | func (s *Server) Start() error { 46 | log.Printf("HTTP server starting on %s", s.config.ServerAddress) 47 | return s.server.ListenAndServe() 48 | } 49 | 50 | // Shutdown gracefully shuts down the server 51 | func (s *Server) Shutdown(ctx context.Context) error { 52 | return s.server.Shutdown(ctx) 53 | } 54 | -------------------------------------------------------------------------------- /internal/auth/auth.go: -------------------------------------------------------------------------------- 1 | // Package auth provides authentication mechanisms for the MCP registry 2 | package auth 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | 8 | "github.com/modelcontextprotocol/registry/internal/model" 9 | ) 10 | 11 | var ( 12 | // ErrAuthRequired is returned when authentication is required but not provided 13 | ErrAuthRequired = errors.New("authentication required") 14 | // ErrUnsupportedAuthMethod is returned when an unsupported auth method is used 15 | ErrUnsupportedAuthMethod = errors.New("unsupported authentication method") 16 | ) 17 | 18 | // Service defines the authentication service interface 19 | type Service interface { 20 | // StartAuthFlow initiates an authentication flow and returns the flow information 21 | StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error) 22 | 23 | // CheckAuthStatus checks the status of an authentication flow using a status token 24 | CheckAuthStatus(ctx context.Context, statusToken string) (string, error) 25 | 26 | // ValidateAuth validates the authentication credentials 27 | ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error) 28 | } 29 | -------------------------------------------------------------------------------- /internal/auth/github.go: -------------------------------------------------------------------------------- 1 | // Package auth provides authentication mechanisms for the MCP registry 2 | package auth 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "regexp" 13 | ) 14 | 15 | var ( 16 | // ErrAuthFailed is returned when authentication fails 17 | ErrAuthFailed = errors.New("authentication failed") 18 | // ErrInvalidToken is returned when a token is invalid 19 | ErrInvalidToken = errors.New("invalid token") 20 | // ErrMissingScope is returned when a token doesn't have the required scope 21 | ErrMissingScope = errors.New("token missing required scope") 22 | ) 23 | 24 | // GitHubOAuthConfig holds the configuration for GitHub OAuth 25 | type GitHubOAuthConfig struct { 26 | ClientID string 27 | ClientSecret string 28 | } 29 | 30 | // DeviceCodeResponse represents the response from GitHub's device code endpoint 31 | type DeviceCodeResponse struct { 32 | DeviceCode string `json:"device_code"` 33 | UserCode string `json:"user_code"` 34 | VerificationURI string `json:"verification_uri"` 35 | ExpiresIn int `json:"expires_in"` 36 | Interval int `json:"interval"` 37 | } 38 | 39 | // AccessTokenResponse represents the response from GitHub's access token endpoint 40 | type AccessTokenResponse struct { 41 | AccessToken string `json:"access_token"` 42 | TokenType string `json:"token_type"` 43 | Scope string `json:"scope"` 44 | Error string `json:"error,omitempty"` 45 | } 46 | 47 | // TokenValidationResponse represents the response from GitHub's token validation endpoint 48 | type TokenValidationResponse struct { 49 | ID int `json:"id"` 50 | URL string `json:"url"` 51 | Scopes []string `json:"scopes"` 52 | SingleFile string `json:"single_file,omitempty"` 53 | Repository string `json:"repository,omitempty"` 54 | Fingerprint string `json:"fingerprint,omitempty"` 55 | Error string `json:"error,omitempty"` 56 | } 57 | 58 | // GitHubDeviceAuth provides methods for GitHub device OAuth authentication 59 | type GitHubDeviceAuth struct { 60 | config GitHubOAuthConfig 61 | } 62 | 63 | // NewGitHubDeviceAuth creates a new GitHub device auth instance 64 | func NewGitHubDeviceAuth(config GitHubOAuthConfig) *GitHubDeviceAuth { 65 | return &GitHubDeviceAuth{ 66 | config: config, 67 | } 68 | } 69 | 70 | // ValidateToken validates if a GitHub token has the necessary permissions to access the required repository. 71 | // It verifies the token owner matches the repository owner or is a member of the owning organization. 72 | // It also verifies that the token was created for the same ClientID used to set up the authentication. 73 | // Returns true if valid, false otherwise along with an error explaining the validation failure. 74 | func (g *GitHubDeviceAuth) ValidateToken(ctx context.Context, token string, requiredRepo string) (bool, error) { 75 | // If no repo is required, we can't validate properly 76 | if requiredRepo == "" { 77 | return false, fmt.Errorf("repository reference is required for token validation") 78 | } 79 | 80 | // First, validate that the token is associated with our ClientID 81 | tokenReq, err := http.NewRequestWithContext( 82 | ctx, 83 | http.MethodGet, 84 | "https://api.github.com/applications/"+g.config.ClientID+"/token", 85 | nil, 86 | ) 87 | if err != nil { 88 | return false, err 89 | } 90 | 91 | // The applications endpoint requires basic auth with client ID and secret 92 | tokenReq.SetBasicAuth(g.config.ClientID, g.config.ClientSecret) 93 | tokenReq.Header.Set("Accept", "application/vnd.github+json") 94 | 95 | // Create request body with the token 96 | type tokenCheck struct { 97 | AccessToken string `json:"access_token"` 98 | } 99 | 100 | checkBody, err := json.Marshal(tokenCheck{AccessToken: token}) 101 | if err != nil { 102 | return false, err 103 | } 104 | 105 | // POST instead of GET for security reasons per GitHub API 106 | tokenURL := "https://api.github.com/applications/" + g.config.ClientID + "/token" 107 | tokenReq, err = http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, io.NopCloser(bytes.NewReader(checkBody))) 108 | if err != nil { 109 | return false, err 110 | } 111 | 112 | tokenReq.SetBasicAuth(g.config.ClientID, g.config.ClientSecret) 113 | tokenReq.Header.Set("Accept", "application/vnd.github+json") 114 | tokenReq.Header.Set("Content-Type", "application/json") 115 | 116 | client := &http.Client{} 117 | tokenResp, err := client.Do(tokenReq) 118 | if err != nil { 119 | return false, err 120 | } 121 | defer tokenResp.Body.Close() 122 | 123 | // Check response - 200 means token is valid and associated with our app 124 | // 404 means token is not associated with our app 125 | if tokenResp.StatusCode != http.StatusOK { 126 | return false, fmt.Errorf("token is not associated with this application (status: %d)", tokenResp.StatusCode) 127 | } 128 | 129 | var tokenInfo TokenValidationResponse 130 | tokenRespBody, err := io.ReadAll(tokenResp.Body) 131 | if err != nil { 132 | return false, err 133 | } 134 | 135 | if err := json.Unmarshal(tokenRespBody, &tokenInfo); err != nil { 136 | return false, err 137 | } 138 | 139 | // Check if there's an error in the response 140 | if tokenInfo.Error != "" { 141 | return false, fmt.Errorf("token validation error: %s", tokenInfo.Error) 142 | } 143 | 144 | // Get the authenticated user 145 | userReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) 146 | if err != nil { 147 | return false, err 148 | } 149 | 150 | userReq.Header.Set("Accept", "application/vnd.github+json") 151 | userReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 152 | client = &http.Client{} 153 | userResp, err := client.Do(userReq) 154 | if err != nil { 155 | return false, err 156 | } 157 | defer userResp.Body.Close() 158 | 159 | if userResp.StatusCode != http.StatusOK { 160 | return false, fmt.Errorf("failed to get user info: status %d", userResp.StatusCode) 161 | } 162 | 163 | var userInfo struct { 164 | Login string `json:"login"` 165 | } 166 | 167 | userBody, err := io.ReadAll(userResp.Body) 168 | if err != nil { 169 | return false, err 170 | } 171 | 172 | if err := json.Unmarshal(userBody, &userInfo); err != nil { 173 | return false, err 174 | } 175 | 176 | // Extract owner from the required repo 177 | owner, _, err := g.ExtractGitHubRepoFromName(requiredRepo) 178 | if err != nil { 179 | return false, err 180 | } 181 | 182 | // Verify that the authenticated user matches the owner 183 | if userInfo.Login != owner { 184 | // Check if the user is a member of the organization 185 | isMember, err := g.checkOrgMembership(ctx, token, userInfo.Login, owner) 186 | if err != nil { 187 | return false, fmt.Errorf("failed to check org membership: %s", owner) 188 | } 189 | 190 | if !isMember { 191 | return false, fmt.Errorf( 192 | "token belongs to user %s, but repository is owned by %s and user is not a member of the organization", 193 | userInfo.Login, owner) 194 | } 195 | } 196 | 197 | // If we've reached this point, the token has access the repo and the user matches 198 | // the owner or is a member of the owner org 199 | return true, nil 200 | } 201 | 202 | func (g *GitHubDeviceAuth) ExtractGitHubRepoFromName(n string) (owner, repo string, err error) { 203 | // match io.github./ 204 | regexp := regexp.MustCompile(`io\.github\.([^/]+)/([^/]+)`) 205 | matches := regexp.FindStringSubmatch(n) 206 | if len(matches) != 3 { 207 | return "", "", fmt.Errorf("invalid GitHub repository name: %s", n) 208 | } 209 | return matches[1], matches[2], nil 210 | } 211 | 212 | // extractGitHubRepo extracts the owner and repository name from a GitHub repository URL 213 | func (g *GitHubDeviceAuth) ExtractGitHubRepo(repoURL string) (owner, repo string, err error) { 214 | regexp := regexp.MustCompile(`github\.com/([^/]+)/([^/]+)`) 215 | matches := regexp.FindStringSubmatch(repoURL) 216 | if len(matches) != 3 { 217 | return "", "", fmt.Errorf("invalid GitHub repository URL: %s", repoURL) 218 | } 219 | return matches[1], matches[2], nil 220 | } 221 | 222 | // checkOrgMembership checks if a user is a member of an organization 223 | func (g *GitHubDeviceAuth) checkOrgMembership(ctx context.Context, token, username, org string) (bool, error) { 224 | // Create request to check if user is a member of the organization 225 | // GitHub API endpoint: GET /orgs/{org}/members/{username} 226 | // true if status code is 204 No Content 227 | // false if status code is 404 Not Found 228 | 229 | url := fmt.Sprint("https://api.github.com/orgs/", org, "/members/", username) 230 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 231 | if err != nil { 232 | return false, err 233 | } 234 | 235 | req.Header.Set("Accept", "application/vnd.github+json") 236 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 237 | 238 | client := &http.Client{} 239 | resp, err := client.Do(req) 240 | if err != nil { 241 | return false, err 242 | } 243 | defer resp.Body.Close() 244 | 245 | if resp.StatusCode == http.StatusNoContent { 246 | return true, nil 247 | } 248 | 249 | return false, fmt.Errorf("failed to check org membership: status %d", resp.StatusCode) 250 | } 251 | -------------------------------------------------------------------------------- /internal/auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/modelcontextprotocol/registry/internal/config" 8 | "github.com/modelcontextprotocol/registry/internal/model" 9 | ) 10 | 11 | // ServiceImpl implements the Service interface 12 | type ServiceImpl struct { 13 | config *config.Config 14 | githubAuth *GitHubDeviceAuth 15 | } 16 | 17 | // NewAuthService creates a new authentication service 18 | // 19 | //nolint:ireturn // Factory function intentionally returns interface for dependency injection 20 | func NewAuthService(cfg *config.Config) Service { 21 | githubConfig := GitHubOAuthConfig{ 22 | ClientID: cfg.GithubClientID, 23 | ClientSecret: cfg.GithubClientSecret, 24 | } 25 | 26 | return &ServiceImpl{ 27 | config: cfg, 28 | githubAuth: NewGitHubDeviceAuth(githubConfig), 29 | } 30 | } 31 | 32 | func (s *ServiceImpl) StartAuthFlow(_ context.Context, _ model.AuthMethod, 33 | _ string) (map[string]string, string, error) { 34 | // return not implemented error 35 | return nil, "", fmt.Errorf("not implemented") 36 | } 37 | 38 | func (s *ServiceImpl) CheckAuthStatus(_ context.Context, _ string) (string, error) { 39 | // return not implemented error 40 | return "", fmt.Errorf("not implemented") 41 | } 42 | 43 | // ValidateAuth validates authentication credentials 44 | func (s *ServiceImpl) ValidateAuth(ctx context.Context, auth model.Authentication) (bool, error) { 45 | // If authentication is required but not provided 46 | if auth.Method == "" || auth.Method == model.AuthMethodNone { 47 | return false, ErrAuthRequired 48 | } 49 | 50 | switch auth.Method { 51 | case model.AuthMethodGitHub: 52 | // Extract repo reference from the repository URL if it's not provided 53 | return s.githubAuth.ValidateToken(ctx, auth.Token, auth.RepoRef) 54 | case model.AuthMethodNone: 55 | return false, ErrAuthRequired 56 | default: 57 | return false, ErrUnsupportedAuthMethod 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | env "github.com/caarlos0/env/v11" 5 | ) 6 | 7 | type DatabaseType string 8 | 9 | const ( 10 | DatabaseTypeMongoDB DatabaseType = "mongodb" 11 | DatabaseTypeMemory DatabaseType = "memory" 12 | ) 13 | 14 | // Config holds the application configuration 15 | type Config struct { 16 | ServerAddress string `env:"SERVER_ADDRESS" envDefault:":8080"` 17 | DatabaseType DatabaseType `env:"DATABASE_TYPE" envDefault:"mongodb"` 18 | DatabaseURL string `env:"DATABASE_URL" envDefault:"mongodb://localhost:27017"` 19 | DatabaseName string `env:"DATABASE_NAME" envDefault:"mcp-registry"` 20 | CollectionName string `env:"COLLECTION_NAME" envDefault:"servers_v2"` 21 | LogLevel string `env:"LOG_LEVEL" envDefault:"info"` 22 | SeedFilePath string `env:"SEED_FILE_PATH" envDefault:"data/seed.json"` 23 | SeedImport bool `env:"SEED_IMPORT" envDefault:"true"` 24 | Version string `env:"VERSION" envDefault:"dev"` 25 | GithubClientID string `env:"GITHUB_CLIENT_ID" envDefault:""` 26 | GithubClientSecret string `env:"GITHUB_CLIENT_SECRET" envDefault:""` 27 | } 28 | 29 | // NewConfig creates a new configuration with default values 30 | func NewConfig() *Config { 31 | var cfg Config 32 | err := env.ParseWithOptions(&cfg, env.Options{ 33 | Prefix: "MCP_REGISTRY_", 34 | }) 35 | if err != nil { 36 | panic(err) 37 | } 38 | return &cfg 39 | } 40 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/modelcontextprotocol/registry/internal/model" 8 | ) 9 | 10 | // Common database errors 11 | var ( 12 | ErrNotFound = errors.New("record not found") 13 | ErrAlreadyExists = errors.New("record already exists") 14 | ErrInvalidInput = errors.New("invalid input") 15 | ErrDatabase = errors.New("database error") 16 | ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version") 17 | ) 18 | 19 | // Database defines the interface for database operations on MCPRegistry entries 20 | type Database interface { 21 | // List retrieves all MCPRegistry entries with optional filtering 22 | List(ctx context.Context, filter map[string]interface{}, cursor string, limit int) ([]*model.Server, string, error) 23 | // GetByID retrieves a single ServerDetail by it's ID 24 | GetByID(ctx context.Context, id string) (*model.ServerDetail, error) 25 | // Publish adds a new ServerDetail to the database 26 | Publish(ctx context.Context, serverDetail *model.ServerDetail) error 27 | // ImportSeed imports initial data from a seed file 28 | ImportSeed(ctx context.Context, seedFilePath string) error 29 | // Close closes the database connection 30 | Close() error 31 | } 32 | 33 | // ConnectionType represents the type of database connection 34 | type ConnectionType string 35 | 36 | const ( 37 | // ConnectionTypeMemory represents an in-memory database connection 38 | ConnectionTypeMemory ConnectionType = "memory" 39 | // ConnectionTypeMongoDB represents a MongoDB database connection 40 | ConnectionTypeMongoDB ConnectionType = "mongodb" 41 | ) 42 | 43 | // ConnectionInfo provides information about the database connection 44 | type ConnectionInfo struct { 45 | // Type indicates the type of database connection 46 | Type ConnectionType 47 | // IsConnected indicates whether the database is currently connected 48 | IsConnected bool 49 | // Raw provides access to the underlying connection object, which will vary by implementation 50 | // For MongoDB, this will be *mongo.Client 51 | // For MemoryDB, this will be map[string]*model.MCPRegistry 52 | Raw any 53 | } 54 | -------------------------------------------------------------------------------- /internal/database/import.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/modelcontextprotocol/registry/internal/model" 11 | ) 12 | 13 | // ReadSeedFile reads and parses the seed.json file - exported for use by all database implementations 14 | func ReadSeedFile(path string) ([]model.ServerDetail, error) { 15 | log.Printf("Reading seed file from %s", path) 16 | 17 | // Set default seed file path if not provided 18 | if path == "" { 19 | // Try to find the seed.json in the data directory 20 | path = filepath.Join("data", "seed.json") 21 | if _, err := os.Stat(path); os.IsNotExist(err) { 22 | return nil, fmt.Errorf("seed file not found at %s", path) 23 | } 24 | } 25 | 26 | // Read the file content 27 | fileContent, err := os.ReadFile(path) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to read file: %w", err) 30 | } 31 | 32 | // Parse the JSON content 33 | var servers []model.ServerDetail 34 | if err := json.Unmarshal(fileContent, &servers); err != nil { 35 | // Try parsing as a raw JSON array and then convert to our model 36 | var rawData []map[string]interface{} 37 | if jsonErr := json.Unmarshal(fileContent, &rawData); jsonErr != nil { 38 | return nil, fmt.Errorf("failed to parse JSON: %w (original error: %w)", jsonErr, err) 39 | } 40 | } 41 | 42 | log.Printf("Found %d server entries in seed file", len(servers)) 43 | return servers, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/database/memory.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/google/uuid" 14 | "github.com/modelcontextprotocol/registry/internal/model" 15 | ) 16 | 17 | // MemoryDB is an in-memory implementation of the Database interface 18 | type MemoryDB struct { 19 | entries map[string]*model.ServerDetail 20 | mu sync.RWMutex 21 | } 22 | 23 | // NewMemoryDB creates a new instance of the in-memory database 24 | func NewMemoryDB(e map[string]*model.Server) *MemoryDB { 25 | // Convert Server entries to ServerDetail entries 26 | serverDetails := make(map[string]*model.ServerDetail) 27 | for k, v := range e { 28 | serverDetails[k] = &model.ServerDetail{ 29 | Server: *v, 30 | } 31 | } 32 | return &MemoryDB{ 33 | entries: serverDetails, 34 | } 35 | } 36 | 37 | // compareSemanticVersions compares two semantic version strings 38 | // Returns: 39 | // 40 | // -1 if version1 < version2 41 | // 0 if version1 == version2 42 | // +1 if version1 > version2 43 | func compareSemanticVersions(version1, version2 string) int { 44 | // Simple semantic version comparison 45 | // Assumes format: major.minor.patch 46 | 47 | parts1 := strings.Split(version1, ".") 48 | parts2 := strings.Split(version2, ".") 49 | 50 | // Pad with zeros if needed 51 | maxLen := len(parts1) 52 | if len(parts2) > maxLen { 53 | maxLen = len(parts2) 54 | } 55 | 56 | for len(parts1) < maxLen { 57 | parts1 = append(parts1, "0") 58 | } 59 | for len(parts2) < maxLen { 60 | parts2 = append(parts2, "0") 61 | } 62 | 63 | // Compare each part 64 | for i := 0; i < maxLen; i++ { 65 | num1, err1 := strconv.Atoi(parts1[i]) 66 | num2, err2 := strconv.Atoi(parts2[i]) 67 | 68 | // If parsing fails, fall back to string comparison 69 | if err1 != nil || err2 != nil { 70 | if parts1[i] < parts2[i] { 71 | return -1 72 | } else if parts1[i] > parts2[i] { 73 | return 1 74 | } 75 | continue 76 | } 77 | 78 | if num1 < num2 { 79 | return -1 80 | } else if num1 > num2 { 81 | return 1 82 | } 83 | } 84 | 85 | return 0 86 | } 87 | 88 | // List retrieves all MCPRegistry entries with optional filtering and pagination 89 | // 90 | //gocognit:ignore 91 | func (db *MemoryDB) List( 92 | ctx context.Context, 93 | filter map[string]interface{}, 94 | cursor string, 95 | limit int, 96 | ) ([]*model.Server, string, error) { 97 | if ctx.Err() != nil { 98 | return nil, "", ctx.Err() 99 | } 100 | 101 | if limit <= 0 { 102 | limit = 10 // Default limit 103 | } 104 | 105 | db.mu.RLock() 106 | defer db.mu.RUnlock() 107 | 108 | // Convert all entries to a slice for pagination 109 | var allEntries []*model.Server 110 | for _, entry := range db.entries { 111 | serverCopy := entry.Server 112 | allEntries = append(allEntries, &serverCopy) 113 | } 114 | 115 | // Simple filtering implementation 116 | var filteredEntries []*model.Server 117 | for _, entry := range allEntries { 118 | include := true 119 | 120 | // Apply filters if any 121 | for key, value := range filter { 122 | switch key { 123 | case "name": 124 | if entry.Name != value.(string) { 125 | include = false 126 | } 127 | case "repoUrl": 128 | if entry.Repository.URL != value.(string) { 129 | include = false 130 | } 131 | case "serverDetail.id": 132 | if entry.ID != value.(string) { 133 | include = false 134 | } 135 | case "version": 136 | if entry.VersionDetail.Version != value.(string) { 137 | include = false 138 | } 139 | // Add more filter options as needed 140 | } 141 | } 142 | 143 | if include { 144 | filteredEntries = append(filteredEntries, entry) 145 | } 146 | } 147 | 148 | // Find starting point for cursor-based pagination 149 | startIdx := 0 150 | if cursor != "" { 151 | for i, entry := range filteredEntries { 152 | if entry.ID == cursor { 153 | startIdx = i + 1 // Start after the cursor 154 | break 155 | } 156 | } 157 | } 158 | 159 | // Sort filteredEntries by ID for consistent pagination 160 | sort.Slice(filteredEntries, func(i, j int) bool { 161 | return filteredEntries[i].ID < filteredEntries[j].ID 162 | }) 163 | 164 | // Apply pagination 165 | endIdx := startIdx + limit 166 | if endIdx > len(filteredEntries) { 167 | endIdx = len(filteredEntries) 168 | } 169 | 170 | var result []*model.Server 171 | if startIdx < len(filteredEntries) { 172 | result = filteredEntries[startIdx:endIdx] 173 | } else { 174 | result = []*model.Server{} 175 | } 176 | 177 | // Determine next cursor 178 | nextCursor := "" 179 | if endIdx < len(filteredEntries) { 180 | nextCursor = filteredEntries[endIdx-1].ID 181 | } 182 | 183 | return result, nextCursor, nil 184 | } 185 | 186 | // GetByID retrieves a single ServerDetail by its ID 187 | func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) { 188 | if ctx.Err() != nil { 189 | return nil, ctx.Err() 190 | } 191 | 192 | db.mu.RLock() 193 | defer db.mu.RUnlock() 194 | 195 | if entry, exists := db.entries[id]; exists { 196 | // Return a copy of the ServerDetail 197 | serverDetailCopy := *entry 198 | return &serverDetailCopy, nil 199 | } 200 | 201 | return nil, ErrNotFound 202 | } 203 | 204 | // Publish adds a new ServerDetail to the database 205 | func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetail) error { 206 | if ctx.Err() != nil { 207 | return ctx.Err() 208 | } 209 | 210 | db.mu.Lock() 211 | defer db.mu.Unlock() 212 | 213 | // check for name 214 | if serverDetail.Name == "" { 215 | return ErrInvalidInput 216 | } 217 | 218 | // check that the name and the version are unique 219 | // Also check version ordering - don't allow publishing older versions after newer ones 220 | var latestVersion string 221 | for _, entry := range db.entries { 222 | if entry.Name == serverDetail.Name { 223 | if entry.VersionDetail.Version == serverDetail.VersionDetail.Version { 224 | return ErrAlreadyExists 225 | } 226 | 227 | // Track the latest version for this package name 228 | if latestVersion == "" || compareSemanticVersions(entry.VersionDetail.Version, latestVersion) > 0 { 229 | latestVersion = entry.VersionDetail.Version 230 | } 231 | } 232 | } 233 | 234 | // If we found existing versions, check if the new version is older than the latest 235 | if latestVersion != "" && compareSemanticVersions(serverDetail.VersionDetail.Version, latestVersion) < 0 { 236 | return ErrInvalidVersion 237 | } 238 | 239 | if serverDetail.Repository.URL == "" { 240 | return ErrInvalidInput 241 | } 242 | 243 | // Generate a new ID for the server detail 244 | serverDetail.ID = uuid.New().String() 245 | serverDetail.VersionDetail.IsLatest = true // Assume the new version is the latest 246 | serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) 247 | // Store a copy of the entire ServerDetail 248 | serverDetailCopy := *serverDetail 249 | db.entries[serverDetail.ID] = &serverDetailCopy 250 | 251 | return nil 252 | } 253 | 254 | // ImportSeed imports initial data from a seed file into memory database 255 | func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error { 256 | if ctx.Err() != nil { 257 | return ctx.Err() 258 | } 259 | 260 | // Read the seed file 261 | seedData, err := ReadSeedFile(seedFilePath) 262 | if err != nil { 263 | return fmt.Errorf("failed to read seed file: %w", err) 264 | } 265 | 266 | log.Printf("Importing %d servers into memory database", len(seedData)) 267 | 268 | db.mu.Lock() 269 | defer db.mu.Unlock() 270 | 271 | for i, server := range seedData { 272 | if server.ID == "" || server.Name == "" { 273 | log.Printf("Skipping server %d: ID or Name is empty", i+1) 274 | continue 275 | } 276 | 277 | // Set default version information if missing 278 | if server.VersionDetail.Version == "" { 279 | server.VersionDetail.Version = "0.0.1-seed" 280 | server.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) 281 | server.VersionDetail.IsLatest = true 282 | } 283 | 284 | // Store a copy of the server detail 285 | serverDetailCopy := server 286 | db.entries[server.ID] = &serverDetailCopy 287 | 288 | log.Printf("[%d/%d] Imported server: %s", i+1, len(seedData), server.Name) 289 | } 290 | 291 | log.Println("Memory database import completed successfully") 292 | return nil 293 | } 294 | 295 | // Close closes the database connection 296 | // For an in-memory database, this is a no-op 297 | func (db *MemoryDB) Close() error { 298 | return nil 299 | } 300 | 301 | // Connection returns information about the database connection 302 | func (db *MemoryDB) Connection() *ConnectionInfo { 303 | return &ConnectionInfo{ 304 | Type: ConnectionTypeMemory, 305 | IsConnected: true, // Memory DB is always connected 306 | Raw: db.entries, 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /internal/database/mongo.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | "github.com/modelcontextprotocol/registry/internal/model" 12 | "go.mongodb.org/mongo-driver/bson" 13 | "go.mongodb.org/mongo-driver/mongo" 14 | "go.mongodb.org/mongo-driver/mongo/options" 15 | ) 16 | 17 | // MongoDB is an implementation of the Database interface using MongoDB 18 | type MongoDB struct { 19 | client *mongo.Client 20 | database *mongo.Database 21 | collection *mongo.Collection 22 | } 23 | 24 | // NewMongoDB creates a new instance of the MongoDB database 25 | func NewMongoDB(ctx context.Context, connectionURI, databaseName, collectionName string) (*MongoDB, error) { 26 | // Set client options and connect to MongoDB 27 | clientOptions := options.Client().ApplyURI(connectionURI) 28 | client, err := mongo.Connect(ctx, clientOptions) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | // Ping the MongoDB server to verify the connection 34 | if err = client.Ping(ctx, nil); err != nil { 35 | return nil, err 36 | } 37 | 38 | // Get database and collection 39 | database := client.Database(databaseName) 40 | collection := database.Collection(collectionName) 41 | 42 | // Create indexes for better query performance 43 | models := []mongo.IndexModel{ 44 | { 45 | Keys: bson.D{bson.E{Key: "name", Value: 1}}, 46 | }, 47 | { 48 | Keys: bson.D{bson.E{Key: "id", Value: 1}}, 49 | Options: options.Index().SetUnique(true), 50 | }, 51 | // add an index for the combination of name and version 52 | { 53 | Keys: bson.D{bson.E{Key: "name", Value: 1}, bson.E{Key: "versiondetail.version", Value: 1}}, 54 | Options: options.Index().SetUnique(true), 55 | }, 56 | } 57 | 58 | _, err = collection.Indexes().CreateMany(ctx, models) 59 | if err != nil { 60 | // Mongo will error if the index already exists, we can ignore this and continue. 61 | var commandError mongo.CommandError 62 | if errors.As(err, &commandError) && commandError.Code != 86 { 63 | return nil, err 64 | } 65 | log.Printf("Indexes already exists, skipping.") 66 | } 67 | 68 | return &MongoDB{ 69 | client: client, 70 | database: database, 71 | collection: collection, 72 | }, nil 73 | } 74 | 75 | // List retrieves MCPRegistry entries with optional filtering and pagination 76 | func (db *MongoDB) List( 77 | ctx context.Context, 78 | filter map[string]interface{}, 79 | cursor string, 80 | limit int, 81 | ) ([]*model.Server, string, error) { 82 | if limit <= 0 { 83 | // Set default limit if not provided 84 | limit = 10 85 | } 86 | 87 | if ctx.Err() != nil { 88 | return nil, "", ctx.Err() 89 | } 90 | 91 | // Convert Go map to MongoDB filter 92 | mongoFilter := bson.M{ 93 | "version_detail.is_latest": true, 94 | } 95 | // Map common filter keys to MongoDB document paths 96 | for k, v := range filter { 97 | // Handle nested fields with dot notation 98 | switch k { 99 | case "version": 100 | mongoFilter["version_detail.version"] = v 101 | case "name": 102 | mongoFilter["name"] = v 103 | default: 104 | mongoFilter[k] = v 105 | } 106 | } 107 | 108 | // Setup pagination options 109 | findOptions := options.Find() 110 | 111 | // If cursor is provided, add condition to filter to only get records after the cursor 112 | if cursor != "" { 113 | // Validate that the cursor is a valid UUID 114 | if _, err := uuid.Parse(cursor); err != nil { 115 | return nil, "", fmt.Errorf("invalid cursor format: %w", err) 116 | } 117 | 118 | // Fetch the document at the cursor to get its sort values 119 | var cursorDoc model.Server 120 | err := db.collection.FindOne(ctx, bson.M{"id": cursor}).Decode(&cursorDoc) 121 | if err != nil { 122 | if !errors.Is(err, mongo.ErrNoDocuments) { 123 | return nil, "", err 124 | } 125 | // If cursor document not found, start from beginning 126 | } else { 127 | // Use the cursor document's ID to paginate (records with ID > cursor's ID) 128 | mongoFilter["id"] = bson.M{"$gt": cursor} 129 | } 130 | } 131 | 132 | // Set sort order by ID (for consistent pagination) 133 | findOptions.SetSort(bson.M{"id": 1}) 134 | 135 | // Set limit if provided and valid 136 | if limit > 0 { 137 | findOptions.SetLimit(int64(limit)) 138 | } 139 | 140 | // Execute find operation with options 141 | mongoCursor, err := db.collection.Find(ctx, mongoFilter, findOptions) 142 | if err != nil { 143 | return nil, "", err 144 | } 145 | defer mongoCursor.Close(ctx) 146 | 147 | // Decode results 148 | var results []*model.Server 149 | if err = mongoCursor.All(ctx, &results); err != nil { 150 | return nil, "", err 151 | } 152 | 153 | // Determine the next cursor 154 | nextCursor := "" 155 | if len(results) > 0 && limit > 0 && len(results) >= limit { 156 | // Use the last item's ID as the next cursor 157 | nextCursor = results[len(results)-1].ID 158 | } 159 | 160 | return results, nextCursor, nil 161 | } 162 | 163 | // GetByID retrieves a single ServerDetail by its ID 164 | func (db *MongoDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) { 165 | if ctx.Err() != nil { 166 | return nil, ctx.Err() 167 | } 168 | 169 | // Create a filter for the ID 170 | filter := bson.M{"id": id} 171 | 172 | // Find the entry in the database 173 | var entry model.ServerDetail 174 | err := db.collection.FindOne(ctx, filter).Decode(&entry) 175 | if err != nil { 176 | if errors.Is(err, mongo.ErrNoDocuments) { 177 | return nil, ErrNotFound 178 | } 179 | return nil, fmt.Errorf("error retrieving entry: %w", err) 180 | } 181 | 182 | // Create and return a ServerDetail from the entry data 183 | return &entry, nil 184 | } 185 | 186 | // Publish adds a new ServerDetail to the database 187 | func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail) error { 188 | if ctx.Err() != nil { 189 | return ctx.Err() 190 | } 191 | // find a server detail with the same name and check that the current version is greater than the existing one 192 | filter := bson.M{ 193 | "name": serverDetail.Name, 194 | "version_detail.is_latest": true, 195 | } 196 | 197 | var existingEntry model.ServerDetail 198 | err := db.collection.FindOne(ctx, filter).Decode(&existingEntry) 199 | if err != nil && !errors.Is(err, mongo.ErrNoDocuments) { 200 | return fmt.Errorf("error checking existing entry: %w", err) 201 | } 202 | 203 | // check that the current version is greater than the existing one 204 | if serverDetail.VersionDetail.Version <= existingEntry.VersionDetail.Version { 205 | return fmt.Errorf("version must be greater than existing version") 206 | } 207 | 208 | serverDetail.ID = uuid.New().String() 209 | serverDetail.VersionDetail.IsLatest = true 210 | serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) 211 | 212 | // Insert the entry into the database 213 | _, err = db.collection.InsertOne(ctx, serverDetail) 214 | if err != nil { 215 | if mongo.IsDuplicateKeyError(err) { 216 | return ErrAlreadyExists 217 | } 218 | return fmt.Errorf("error inserting entry: %w", err) 219 | } 220 | 221 | // update the existing entry to not be the latest version 222 | if existingEntry.ID != "" { 223 | _, err = db.collection.UpdateOne( 224 | ctx, 225 | bson.M{"id": existingEntry.ID}, 226 | bson.M{"$set": bson.M{"versiondetail.islatest": false}}) 227 | if err != nil { 228 | return fmt.Errorf("error updating existing entry: %w", err) 229 | } 230 | } 231 | 232 | return nil 233 | } 234 | 235 | // ImportSeed imports initial data from a seed file into MongoDB 236 | func (db *MongoDB) ImportSeed(ctx context.Context, seedFilePath string) error { 237 | // Read the seed file 238 | servers, err := ReadSeedFile(seedFilePath) 239 | if err != nil { 240 | return fmt.Errorf("failed to read seed file: %w", err) 241 | } 242 | 243 | collection := db.collection 244 | 245 | log.Printf("Importing %d servers into collection %s", len(servers), collection.Name()) 246 | 247 | for i, server := range servers { 248 | if server.ID == "" || server.Name == "" { 249 | log.Printf("Skipping server %d: ID or Name is empty", i+1) 250 | continue 251 | } 252 | 253 | if server.VersionDetail.Version == "" { 254 | server.VersionDetail.Version = "0.0.1-seed" 255 | server.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) 256 | server.VersionDetail.IsLatest = true 257 | } 258 | 259 | // Create filter based on server ID 260 | filter := bson.M{"id": server.ID} 261 | 262 | // Create update document 263 | update := bson.M{"$set": server} 264 | 265 | // Use upsert to create if not exists or update if exists 266 | opts := options.Update().SetUpsert(true) 267 | result, err := collection.UpdateOne(ctx, filter, update, opts) 268 | if err != nil { 269 | log.Printf("Error importing server %s: %v", server.ID, err) 270 | continue 271 | } 272 | 273 | switch { 274 | case result.UpsertedCount > 0: 275 | log.Printf("[%d/%d] Created server: %s", i+1, len(servers), server.Name) 276 | case result.ModifiedCount > 0: 277 | log.Printf("[%d/%d] Updated server: %s", i+1, len(servers), server.Name) 278 | default: 279 | log.Printf("[%d/%d] Server already up to date: %s", i+1, len(servers), server.Name) 280 | } 281 | } 282 | 283 | log.Println("MongoDB database import completed successfully") 284 | return nil 285 | } 286 | 287 | // Close closes the database connection 288 | func (db *MongoDB) Close() error { 289 | return db.client.Disconnect(context.Background()) 290 | } 291 | 292 | // Connection returns information about the database connection 293 | func (db *MongoDB) Connection() *ConnectionInfo { 294 | isConnected := false 295 | // Check if the client is connected 296 | if db.client != nil { 297 | // A quick ping with 1 second timeout to verify connection 298 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 299 | defer cancel() 300 | err := db.client.Ping(ctx, nil) 301 | isConnected = (err == nil) 302 | } 303 | 304 | return &ConnectionInfo{ 305 | Type: ConnectionTypeMongoDB, 306 | IsConnected: isConnected, 307 | Raw: db.client, 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /internal/docs/publish_swagger.yaml: -------------------------------------------------------------------------------- 1 | /v0/publish: 2 | post: 3 | tags: 4 | - servers 5 | summary: Publish a server to the registry 6 | description: Adds a new server to the MCP registry with authentication 7 | operationId: publishServer 8 | security: 9 | - bearerAuth: [] 10 | requestBody: 11 | required: true 12 | content: 13 | application/json: 14 | schema: 15 | type: object 16 | properties: 17 | server_detail: 18 | $ref: '#/components/schemas/ServerDetail' 19 | repo_ref: 20 | type: string 21 | description: Repository reference used for authentication (defaults to server name if not provided) 22 | responses: 23 | '201': 24 | description: Server published successfully 25 | content: 26 | application/json: 27 | schema: 28 | type: object 29 | properties: 30 | message: 31 | type: string 32 | example: Server publication successful 33 | id: 34 | type: string 35 | example: 1234567890abcdef12345678 36 | '400': 37 | description: Invalid request 38 | content: 39 | application/json: 40 | schema: 41 | type: object 42 | properties: 43 | error: 44 | type: string 45 | example: Name is required 46 | '401': 47 | description: Authentication failed 48 | content: 49 | application/json: 50 | schema: 51 | type: object 52 | properties: 53 | error: 54 | type: string 55 | example: Authentication is required for publishing 56 | '500': 57 | description: Server error 58 | content: 59 | application/json: 60 | schema: 61 | type: object 62 | properties: 63 | error: 64 | type: string 65 | example: Failed to publish server details 66 | -------------------------------------------------------------------------------- /internal/docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Model Context Protocol Registry API 4 | description: API for the Model Context Protocol Registry 5 | version: "0.1.0" 6 | 7 | servers: 8 | - url: / 9 | description: Default server 10 | 11 | tags: 12 | - name: health 13 | description: Health checking operations 14 | - name: servers 15 | description: Server registry operations 16 | 17 | paths: 18 | /v0/health: 19 | get: 20 | tags: 21 | - health 22 | summary: Health check endpoint 23 | description: Returns the health status of the API 24 | operationId: healthCheck 25 | responses: 26 | '200': 27 | description: Successful operation 28 | content: 29 | application/json: 30 | schema: 31 | type: object 32 | properties: 33 | status: 34 | type: string 35 | example: ok 36 | github_client_id: 37 | type: string 38 | example: "your_github_client_id" 39 | /v0/ping: 40 | get: 41 | tags: 42 | - health 43 | summary: API version check 44 | description: Returns the API version and status 45 | operationId: pingCheck 46 | responses: 47 | '200': 48 | description: Successful operation 49 | content: 50 | application/json: 51 | schema: 52 | type: object 53 | properties: 54 | status: 55 | type: string 56 | example: ok 57 | version: 58 | type: string 59 | example: "0.1.0" 60 | '405': 61 | description: Method not allowed 62 | 63 | /v0/servers: 64 | get: 65 | tags: 66 | - servers 67 | summary: List registered servers 68 | description: Returns a paginated list of registered servers 69 | operationId: listServers 70 | parameters: 71 | - name: cursor 72 | in: query 73 | description: Pagination cursor (UUID) 74 | required: false 75 | schema: 76 | type: string 77 | format: uuid 78 | - name: limit 79 | in: query 80 | description: Maximum number of items to return (1-100, default 30) 81 | required: false 82 | schema: 83 | type: integer 84 | minimum: 1 85 | maximum: 100 86 | default: 30 87 | responses: 88 | '200': 89 | description: Successful operation 90 | content: 91 | application/json: 92 | schema: 93 | type: object 94 | properties: 95 | servers: 96 | type: array 97 | items: 98 | $ref: '#/components/schemas/Server' 99 | metadata: 100 | $ref: '#/components/schemas/PaginationMetadata' 101 | '400': 102 | description: Invalid cursor or limit parameter 103 | '405': 104 | description: Method not allowed 105 | 106 | /v0/publish: 107 | post: 108 | tags: 109 | - servers 110 | summary: Publish a server to the registry 111 | description: Adds a new server to the MCP registry with authentication 112 | operationId: publishServer 113 | security: 114 | - bearerAuth: [] 115 | requestBody: 116 | required: true 117 | content: 118 | application/json: 119 | schema: 120 | type: object 121 | properties: 122 | server_detail: 123 | $ref: '#/components/schemas/ServerDetail' 124 | repo_ref: 125 | type: string 126 | description: Repository reference used for authentication (defaults to server name if not provided) 127 | responses: 128 | '201': 129 | description: Server published successfully 130 | content: 131 | application/json: 132 | schema: 133 | type: object 134 | properties: 135 | message: 136 | type: string 137 | example: Server publication successful 138 | id: 139 | type: string 140 | example: 1234567890abcdef12345678 141 | '400': 142 | description: Invalid request 143 | content: 144 | application/json: 145 | schema: 146 | type: object 147 | properties: 148 | error: 149 | type: string 150 | example: Name is required 151 | '401': 152 | description: Authentication failed 153 | content: 154 | application/json: 155 | schema: 156 | type: object 157 | properties: 158 | error: 159 | type: string 160 | example: Authentication is required for publishing 161 | '405': 162 | description: Method not allowed 163 | '500': 164 | description: Server error 165 | content: 166 | application/json: 167 | schema: 168 | type: object 169 | properties: 170 | error: 171 | type: string 172 | example: Failed to publish server details 173 | 174 | /v0/servers/{id}: 175 | get: 176 | tags: 177 | - servers 178 | summary: Get server details 179 | description: Returns details of a specific server by ID 180 | operationId: getServerDetails 181 | parameters: 182 | - name: id 183 | in: path 184 | description: Server ID (UUID) 185 | required: true 186 | schema: 187 | type: string 188 | format: uuid 189 | responses: 190 | '200': 191 | description: Successful operation 192 | content: 193 | application/json: 194 | schema: 195 | $ref: '#/components/schemas/Server' 196 | '400': 197 | description: Invalid ID format 198 | '404': 199 | description: Server not found 200 | '405': 201 | description: Method not allowed 202 | 203 | components: 204 | securitySchemes: 205 | bearerAuth: 206 | type: http 207 | scheme: bearer 208 | bearerFormat: JWT 209 | description: "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"" 210 | 211 | schemas: 212 | Server: 213 | type: object 214 | properties: 215 | id: 216 | type: string 217 | format: uuid 218 | example: "123e4567-e89b-12d3-a456-426614174000" 219 | name: 220 | type: string 221 | example: "Example MCP Server" 222 | url: 223 | type: string 224 | format: uri 225 | example: "https://example.com/mcp" 226 | description: 227 | type: string 228 | example: "An example MCP server" 229 | created_at: 230 | type: string 231 | format: date-time 232 | updated_at: 233 | type: string 234 | format: date-time 235 | 236 | PaginationMetadata: 237 | type: object 238 | properties: 239 | next_cursor: 240 | type: string 241 | format: uuid 242 | example: "123e4567-e89b-12d3-a456-426614174000" 243 | count: 244 | type: integer 245 | example: 30 246 | -------------------------------------------------------------------------------- /internal/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // AuthMethod represents the authentication method used 4 | type AuthMethod string 5 | 6 | const ( 7 | // AuthMethodGitHub represents GitHub OAuth authentication 8 | AuthMethodGitHub AuthMethod = "github" 9 | // AuthMethodNone represents no authentication 10 | AuthMethodNone AuthMethod = "none" 11 | ) 12 | 13 | // Authentication holds information about the authentication method and credentials 14 | type Authentication struct { 15 | Method AuthMethod `json:"method,omitempty"` 16 | Token string `json:"token,omitempty"` 17 | RepoRef string `json:"repo_ref,omitempty"` 18 | } 19 | 20 | // PublishRequest represents a request to publish a server to the registry 21 | type PublishRequest struct { 22 | ServerDetail `json:",inline"` 23 | AuthStatusToken string `json:"-"` // Used internally for device flows 24 | } 25 | 26 | // Repository represents a source code repository as defined in the spec 27 | type Repository struct { 28 | URL string `json:"url" bson:"url"` 29 | Source string `json:"source" bson:"source"` 30 | ID string `json:"id" bson:"id"` 31 | } 32 | 33 | // ServerList represents the response for listing servers as defined in the spec 34 | type ServerList struct { 35 | Servers []Server `json:"servers" bson:"servers"` 36 | Next string `json:"next,omitempty" bson:"next,omitempty"` 37 | TotalCount int `json:"total_count" bson:"total_count"` 38 | } 39 | 40 | // create an enum for Format 41 | type Format string 42 | 43 | const ( 44 | FormatString Format = "string" 45 | FormatNumber Format = "number" 46 | FormatBoolean Format = "boolean" 47 | FormatFilePath Format = "file_path" 48 | ) 49 | 50 | // UserInput represents a user input as defined in the spec 51 | type Input struct { 52 | Description string `json:"description,omitempty" bson:"description,omitempty"` 53 | IsRequired bool `json:"is_required,omitempty" bson:"is_required,omitempty"` 54 | Format Format `json:"format,omitempty" bson:"format,omitempty"` 55 | Value string `json:"value,omitempty" bson:"value,omitempty"` 56 | IsSecret bool `json:"is_secret,omitempty" bson:"is_secret,omitempty"` 57 | Default string `json:"default,omitempty" bson:"default,omitempty"` 58 | Choices []string `json:"choices,omitempty" bson:"choices,omitempty"` 59 | Template string `json:"template,omitempty" bson:"template,omitempty"` 60 | Properties map[string]Input `json:"properties,omitempty" bson:"properties,omitempty"` 61 | } 62 | 63 | type InputWithVariables struct { 64 | Input `json:",inline" bson:",inline"` 65 | Variables map[string]Input `json:"variables,omitempty" bson:"variables,omitempty"` 66 | } 67 | 68 | type KeyValueInput struct { 69 | InputWithVariables `json:",inline" bson:",inline"` 70 | Name string `json:"name" bson:"name"` 71 | } 72 | type ArgumentType string 73 | 74 | const ( 75 | ArgumentTypePositional ArgumentType = "positional" 76 | ArgumentTypeNamed ArgumentType = "named" 77 | ) 78 | 79 | // RuntimeArgument defines a type that can be either a PositionalArgument or a NamedArgument 80 | type Argument struct { 81 | InputWithVariables `json:",inline" bson:",inline"` 82 | Type ArgumentType `json:"type" bson:"type"` 83 | Name string `json:"name,omitempty" bson:"name,omitempty"` 84 | IsRepeated bool `json:"is_repeated,omitempty" bson:"is_repeated,omitempty"` 85 | ValueHint string `json:"value_hint,omitempty" bson:"value_hint,omitempty"` 86 | } 87 | 88 | type Package struct { 89 | RegistryName string `json:"registry_name" bson:"registry_name"` 90 | Name string `json:"name" bson:"name"` 91 | Version string `json:"version" bson:"version"` 92 | RunTimeHint string `json:"runtime_hint,omitempty" bson:"runtime_hint,omitempty"` 93 | RuntimeArguments []Argument `json:"runtime_arguments,omitempty" bson:"runtime_arguments,omitempty"` 94 | PackageArguments []Argument `json:"package_arguments,omitempty" bson:"package_arguments,omitempty"` 95 | EnvironmentVariables []KeyValueInput `json:"environment_variables,omitempty" bson:"environment_variables,omitempty"` 96 | } 97 | 98 | // Remote represents a remote connection endpoint 99 | type Remote struct { 100 | TransportType string `json:"transport_type" bson:"transport_type"` 101 | URL string `json:"url" bson:"url"` 102 | Headers []Input `json:"headers,omitempty" bson:"headers,omitempty"` 103 | } 104 | 105 | // VersionDetail represents the version details of a server 106 | type VersionDetail struct { 107 | Version string `json:"version" bson:"version"` 108 | ReleaseDate string `json:"release_date" bson:"release_date"` 109 | IsLatest bool `json:"is_latest" bson:"is_latest"` 110 | } 111 | 112 | // Server represents a basic server information as defined in the spec 113 | type Server struct { 114 | ID string `json:"id" bson:"id"` 115 | Name string `json:"name" bson:"name"` 116 | Description string `json:"description" bson:"description"` 117 | Repository Repository `json:"repository" bson:"repository"` 118 | VersionDetail VersionDetail `json:"version_detail" bson:"version_detail"` 119 | } 120 | 121 | // ServerDetail represents detailed server information as defined in the spec 122 | type ServerDetail struct { 123 | Server `json:",inline" bson:",inline"` 124 | Packages []Package `json:"packages,omitempty" bson:"packages,omitempty"` 125 | Remotes []Remote `json:"remotes,omitempty" bson:"remotes,omitempty"` 126 | } 127 | -------------------------------------------------------------------------------- /internal/service/fake_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/modelcontextprotocol/registry/internal/database" 9 | "github.com/modelcontextprotocol/registry/internal/model" 10 | ) 11 | 12 | // fakeRegistryService implements RegistryService interface with an in-memory database 13 | type fakeRegistryService struct { 14 | db *database.MemoryDB 15 | } 16 | 17 | // NewFakeRegistryService creates a new fake registry service with pre-populated data 18 | // 19 | //nolint:ireturn // Factory function intentionally returns interface for dependency injection 20 | func NewFakeRegistryService() RegistryService { 21 | // Sample registry entries with updated model structure 22 | registries := []*model.Server{ 23 | { 24 | ID: uuid.New().String(), 25 | Name: "bluegreen/mcp-server", 26 | Description: "A dummy MCP registry for testing", 27 | Repository: model.Repository{ 28 | URL: "https://github.com/example/mcp-1", 29 | Source: "github", 30 | ID: "example/mcp-1", 31 | }, 32 | VersionDetail: model.VersionDetail{ 33 | Version: "1.0.0", 34 | ReleaseDate: time.Now().Format(time.RFC3339), 35 | IsLatest: true, 36 | }, 37 | }, 38 | { 39 | ID: uuid.New().String(), 40 | Name: "orangepurple/mcp-server", 41 | Description: "Another dummy MCP registry for testing", 42 | Repository: model.Repository{ 43 | URL: "https://github.com/example/mcp-2", 44 | Source: "github", 45 | ID: "example/mcp-2", 46 | }, 47 | VersionDetail: model.VersionDetail{ 48 | Version: "0.9.0", 49 | ReleaseDate: time.Now().Format(time.RFC3339), 50 | IsLatest: false, 51 | }, 52 | }, 53 | { 54 | ID: uuid.New().String(), 55 | Name: "greenyellow/mcp-server", 56 | Description: "Yet another dummy MCP registry for testing", 57 | Repository: model.Repository{ 58 | URL: "https://github.com/example/mcp-3", 59 | Source: "github", 60 | ID: "example/mcp-3", 61 | }, 62 | VersionDetail: model.VersionDetail{ 63 | Version: "0.9.5", 64 | ReleaseDate: time.Now().Format(time.RFC3339), 65 | IsLatest: false, 66 | }, 67 | }, 68 | } 69 | 70 | // Create a new in-memory database 71 | registryMap := make(map[string]*model.Server) 72 | for _, entry := range registries { 73 | registryMap[entry.ID] = entry 74 | } 75 | memDB := database.NewMemoryDB(registryMap) 76 | return &fakeRegistryService{ 77 | db: memDB, 78 | } 79 | } 80 | 81 | // List retrieves MCPRegistry entries with optional filtering and pagination 82 | func (s *fakeRegistryService) List(cursor string, limit int) ([]model.Server, string, error) { 83 | // Create a timeout context for the database operation 84 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 85 | defer cancel() 86 | 87 | // Use the database's List method with no filters to get all entries 88 | entries, nextCursor, err := s.db.List(ctx, nil, cursor, limit) 89 | if err != nil { 90 | return nil, "", err 91 | } 92 | // Convert from []*model.Server to []model.Server 93 | result := make([]model.Server, len(entries)) 94 | for i, entry := range entries { 95 | result[i] = *entry 96 | } 97 | 98 | return result, nextCursor, nil 99 | } 100 | 101 | // GetByID retrieves a specific server detail by its ID 102 | func (s *fakeRegistryService) GetByID(id string) (*model.ServerDetail, error) { 103 | // Create a timeout context for the database operation 104 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 105 | defer cancel() 106 | 107 | // Use the database's GetByID method to retrieve the server detail 108 | serverDetail, err := s.db.GetByID(ctx, id) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | return serverDetail, nil 114 | } 115 | 116 | // Publish adds a new server detail to the in-memory database 117 | func (s *fakeRegistryService) Publish(serverDetail *model.ServerDetail) error { 118 | // Create a timeout context for the database operation 119 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 120 | defer cancel() 121 | 122 | // Use the database's Publish method to add the server detail 123 | return s.db.Publish(ctx, serverDetail) 124 | } 125 | 126 | // Close closes the in-memory database connection 127 | func (s *fakeRegistryService) Close() error { 128 | return s.db.Close() 129 | } 130 | -------------------------------------------------------------------------------- /internal/service/registry_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/modelcontextprotocol/registry/internal/database" 8 | "github.com/modelcontextprotocol/registry/internal/model" 9 | ) 10 | 11 | // registryServiceImpl implements the RegistryService interface using our Database 12 | type registryServiceImpl struct { 13 | db database.Database 14 | } 15 | 16 | // NewRegistryServiceWithDB creates a new registry service with the provided database 17 | // 18 | //nolint:ireturn // Factory function intentionally returns interface for dependency injection 19 | func NewRegistryServiceWithDB(db database.Database) RegistryService { 20 | return ®istryServiceImpl{ 21 | db: db, 22 | } 23 | } 24 | 25 | // GetAll returns all registry entries 26 | func (s *registryServiceImpl) GetAll() ([]model.Server, error) { 27 | // Create a timeout context for the database operation 28 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 29 | defer cancel() 30 | 31 | // Use the database's List method with no filters to get all entries 32 | entries, _, err := s.db.List(ctx, nil, "", 30) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | // Convert from []*model.Server to []model.Server 38 | result := make([]model.Server, len(entries)) 39 | for i, entry := range entries { 40 | result[i] = *entry 41 | } 42 | 43 | return result, nil 44 | } 45 | 46 | // List returns registry entries with cursor-based pagination 47 | func (s *registryServiceImpl) List(cursor string, limit int) ([]model.Server, string, error) { 48 | // Create a timeout context for the database operation 49 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 50 | defer cancel() 51 | 52 | // If limit is not set or negative, use a default limit 53 | if limit <= 0 { 54 | limit = 30 55 | } 56 | 57 | // Use the database's List method with pagination 58 | entries, nextCursor, err := s.db.List(ctx, nil, cursor, limit) 59 | if err != nil { 60 | return nil, "", err 61 | } 62 | 63 | // Convert from []*model.Server to []model.Server 64 | result := make([]model.Server, len(entries)) 65 | for i, entry := range entries { 66 | result[i] = *entry 67 | } 68 | 69 | return result, nextCursor, nil 70 | } 71 | 72 | // GetByID retrieves a specific server detail by its ID 73 | func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) { 74 | // Create a timeout context for the database operation 75 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 76 | defer cancel() 77 | 78 | // Use the database's GetByID method to retrieve the server detail 79 | serverDetail, err := s.db.GetByID(ctx, id) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return serverDetail, nil 85 | } 86 | 87 | // Publish adds a new server detail to the registry 88 | func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { 89 | // Create a timeout context for the database operation 90 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 91 | defer cancel() 92 | 93 | if serverDetail == nil { 94 | return database.ErrInvalidInput 95 | } 96 | 97 | err := s.db.Publish(ctx, serverDetail) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/modelcontextprotocol/registry/internal/model" 4 | 5 | // RegistryService defines the interface for registry operations 6 | type RegistryService interface { 7 | List(cursor string, limit int) ([]model.Server, string, error) 8 | GetByID(id string) (*model.ServerDetail, error) 9 | Publish(serverDetail *model.ServerDetail) error 10 | } 11 | -------------------------------------------------------------------------------- /scripts/test_endpoints.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "==================================================" 6 | echo "MCP Registry Endpoint Test Script" 7 | echo "==================================================" 8 | echo "This script expects the MCP Registry server to be running locally." 9 | echo "Please ensure the server is started using one of the following methods:" 10 | echo " • Docker Compose: docker compose up" 11 | echo " • Direct execution: go run cmd/registry/main.go" 12 | echo " • Built binary: ./build/registry" 13 | echo "==================================================" 14 | echo "" 15 | 16 | # Default values 17 | HOST="http://localhost:8080" 18 | ENDPOINT="all" 19 | LIMIT="" 20 | 21 | # Display usage information 22 | function show_usage { 23 | echo "Usage: $0 [options]" 24 | echo "Options:" 25 | echo " -h, --host Base URL of the MCP Registry service (default: http://localhost:8080)" 26 | echo " -e, --endpoint Endpoint to test: health, servers, ping, all (default: all)" 27 | echo " -l, --limit Test servers with specified limit parameter" 28 | echo " --help Show this help message" 29 | exit 1 30 | } 31 | 32 | # Check if jq is installed 33 | if ! command -v jq &> /dev/null; then 34 | echo "Error: jq is required but not installed." 35 | echo "Please install jq using your package manager, for example:" 36 | echo " brew install jq (macOS)" 37 | echo " apt-get install jq (Debian/Ubuntu)" 38 | echo " yum install jq (CentOS/RHEL)" 39 | exit 1 40 | fi 41 | 42 | # Parse command line arguments 43 | while [[ "$#" -gt 0 ]]; do 44 | case $1 in 45 | -h|--host) HOST="$2"; shift ;; 46 | -e|--endpoint) ENDPOINT="$2"; shift ;; 47 | -l|--limit) LIMIT="$2"; shift ;; 48 | --help) show_usage ;; 49 | *) echo "Unknown parameter: $1"; show_usage ;; 50 | esac 51 | shift 52 | done 53 | 54 | # Validate endpoint 55 | if [[ "$ENDPOINT" != "health" && "$ENDPOINT" != "servers" && "$ENDPOINT" != "ping" && "$ENDPOINT" != "all" ]]; then 56 | echo "Invalid endpoint: $ENDPOINT. Must be 'health', 'servers', 'ping', or 'all'." 57 | exit 1 58 | fi 59 | 60 | # Test health endpoint 61 | test_health() { 62 | echo "Testing health endpoint: $HOST/v0/health" 63 | 64 | # Get response and status code 65 | http_response=$(curl -s "$HOST/v0/health") 66 | status_code=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/v0/health") 67 | 68 | echo "Status Code: $status_code" 69 | 70 | if [[ $status_code == 2* ]]; then 71 | # Parse JSON response with jq 72 | echo "Response:" 73 | echo "$http_response" | jq '.' 74 | echo "Health check successful" 75 | echo "-------------------------------------" 76 | return 0 77 | else 78 | echo "Response:" 79 | echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" 80 | echo "Health check failed" 81 | echo "-------------------------------------" 82 | return 1 83 | fi 84 | } 85 | 86 | # Test servers endpoint 87 | test_servers() { 88 | echo "Testing servers endpoint: $HOST/v0/servers" 89 | 90 | # Get response and status code 91 | http_response=$(curl -s "$HOST/v0/servers") 92 | status_code=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/v0/servers") 93 | 94 | echo "Status Code: $status_code" 95 | 96 | if [[ $status_code == 2* ]]; then 97 | # Parse and display JSON with jq 98 | echo "Response Summary:" 99 | echo "$http_response" | jq '.servers | length' | xargs echo "Total registries:" 100 | 101 | # Display a prettier formatted summary - fixed to use lowercase property name 102 | echo "servers Names:" 103 | echo "$http_response" | jq -r '.servers[].name' 104 | 105 | # Show the metadata with next cursor if available 106 | echo -e "\nPagination Metadata:" 107 | echo "$http_response" | jq '.metadata' 108 | 109 | # Show more detailed output with all fields 110 | echo -e "\nservers Details:" 111 | echo "$http_response" | jq '.' 112 | 113 | echo "servers request successful" 114 | echo "-------------------------------------" 115 | return 0 116 | else 117 | echo "Response:" 118 | echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" 119 | echo "servers request failed" 120 | echo "-------------------------------------" 121 | return 1 122 | fi 123 | } 124 | 125 | # Test servers endpoint with limit 126 | test_servers_with_limit() { 127 | limit=$1 128 | echo "Testing servers endpoint with limit: $HOST/v0/servers?limit=$limit" 129 | 130 | # Get response and status code 131 | http_response=$(curl -s "$HOST/v0/servers?limit=$limit") 132 | status_code=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/v0/servers?limit=$limit") 133 | 134 | echo "Status Code: $status_code" 135 | 136 | if [[ $status_code == 2* ]]; then 137 | # Verify the response contains the right number of items (or not more than the limit) 138 | item_count=$(echo "$http_response" | jq '.data | length') 139 | echo "Response has $item_count items (requested limit: $limit)" 140 | 141 | # Verify we're not exceeding the limit 142 | if [[ $item_count -gt $limit ]]; then 143 | echo "ERROR: Response contains more items ($item_count) than the requested limit ($limit)" 144 | return 1 145 | fi 146 | 147 | # Parse and display JSON with jq 148 | echo "Response Summary:" 149 | 150 | # Display a prettier formatted summary 151 | echo "servers Names:" 152 | echo "$http_response" | jq -r '.data[].name' 153 | 154 | # Show the metadata with next cursor if available 155 | echo -e "\nPagination Metadata:" 156 | echo "$http_response" | jq '.metadata' 157 | 158 | # Show more detailed output with all fields 159 | echo -e "\nservers Details:" 160 | echo "$http_response" | jq '.' 161 | 162 | echo "servers request with limit successful" 163 | echo "-------------------------------------" 164 | return 0 165 | else 166 | echo "Response:" 167 | echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" 168 | echo "servers request with limit failed" 169 | echo "-------------------------------------" 170 | return 1 171 | fi 172 | } 173 | 174 | # Test ping endpoint 175 | test_ping() { 176 | echo "Testing ping endpoint: $HOST/v0/ping" 177 | 178 | # Get response and status code 179 | http_response=$(curl -s "$HOST/v0/ping") 180 | status_code=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/v0/ping") 181 | 182 | echo "Status Code: $status_code" 183 | 184 | if [[ $status_code == 2* ]]; then 185 | # Parse JSON response with jq 186 | echo "Response:" 187 | echo "$http_response" | jq '.' 188 | echo "Ping successful" 189 | echo "-------------------------------------" 190 | return 0 191 | else 192 | echo "Response:" 193 | echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" 194 | echo "Ping failed" 195 | echo "-------------------------------------" 196 | return 1 197 | fi 198 | } 199 | 200 | # Run tests based on selected endpoint 201 | success=0 202 | if [[ "$ENDPOINT" == "health" || "$ENDPOINT" == "all" ]]; then 203 | test_health 204 | success=$((success + $?)) 205 | fi 206 | 207 | if [[ "$ENDPOINT" == "servers" || "$ENDPOINT" == "all" ]]; then 208 | if [ -n "$LIMIT" ]; then 209 | test_servers_with_limit "$LIMIT" 210 | else 211 | test_servers 212 | fi 213 | success=$((success + $?)) 214 | fi 215 | 216 | if [[ "$ENDPOINT" == "ping" || "$ENDPOINT" == "all" ]]; then 217 | test_ping 218 | success=$((success + $?)) 219 | fi 220 | 221 | # Return overall success/failure 222 | if [[ $success -eq 0 ]]; then 223 | echo "All tests passed successfully!" 224 | exit 0 225 | else 226 | echo "Some tests failed!" 227 | exit 1 228 | fi -------------------------------------------------------------------------------- /scripts/test_publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "==================================================" 6 | echo "MCP Registry Publish Endpoint Test Script" 7 | echo "==================================================" 8 | echo "This script expects the MCP Registry server to be running locally." 9 | echo "Please ensure the server is started using one of the following methods:" 10 | echo " • Docker Compose: docker compose up" 11 | echo " • Direct execution: go run cmd/registry/main.go" 12 | echo " • Built binary: ./build/registry" 13 | echo "" 14 | echo "REQUIRED: Set the BEARER_TOKEN environment variable with a valid GitHub token" 15 | echo "Example: export BEARER_TOKEN=your_github_token_here" 16 | echo "==================================================" 17 | echo "" 18 | 19 | # Default values 20 | HOST="http://localhost:8080" 21 | VERBOSE=false 22 | 23 | # Display usage information 24 | function show_usage { 25 | echo "Usage: $0 [options]" 26 | echo "Options:" 27 | echo " -h, --host Base URL of the MCP Registry service (default: http://localhost:8080)" 28 | echo " -v, --verbose Show verbose output including full request payload" 29 | echo " --help Show this help message" 30 | echo "" 31 | echo "Environment Variables:" 32 | echo " BEARER_TOKEN Required: GitHub token for authentication" 33 | exit 1 34 | } 35 | 36 | # Check if bearer token is set 37 | if [[ -z "$BEARER_TOKEN" ]]; then 38 | echo "Error: BEARER_TOKEN environment variable is not set." 39 | echo "Please set your GitHub token as an environment variable:" 40 | echo " export BEARER_TOKEN=your_github_token_here" 41 | exit 1 42 | fi 43 | 44 | # Check if jq is installed 45 | if ! command -v jq &> /dev/null; then 46 | echo "Error: jq is required but not installed." 47 | echo "Please install jq using your package manager, for example:" 48 | echo " brew install jq (macOS)" 49 | echo " apt-get install jq (Debian/Ubuntu)" 50 | echo " yum install jq (CentOS/RHEL)" 51 | exit 1 52 | fi 53 | 54 | # Check if the API is running 55 | echo "Checking if the MCP Registry API is running at $HOST..." 56 | health_check=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/v0/health" 2>/dev/null) 57 | if [[ "$health_check" != "200" ]]; then 58 | echo "Error: MCP Registry API is not running at $HOST (health check returned $health_check)" 59 | echo "Please start the server using one of the methods mentioned above and try again." 60 | exit 1 61 | else 62 | echo "✓ MCP Registry API is running at $HOST" 63 | fi 64 | 65 | # Parse command line arguments 66 | while [[ "$#" -gt 0 ]]; do 67 | case $1 in 68 | -h|--host) HOST="$2"; shift ;; 69 | -v|--verbose) VERBOSE=true ;; 70 | --help) show_usage ;; 71 | *) echo "Unknown parameter: $1"; show_usage ;; 72 | esac 73 | shift 74 | done 75 | 76 | # Create a temporary file for our JSON payload 77 | PAYLOAD_FILE=$(mktemp) 78 | 79 | # Create sample server detail payload based on current model structure 80 | cat > "$PAYLOAD_FILE" << EOF 81 | { 82 | "name": "io.github.example/test-mcp-server", 83 | "description": "A test server for MCP Registry validation - published at $(date)", 84 | "repository": { 85 | "url": "https://github.com/example/test-mcp-server", 86 | "source": "github", 87 | "id": "example/test-mcp-server" 88 | }, 89 | "version_detail": { 90 | "version": "1.0.$(date +%s)" 91 | }, 92 | "packages": [ 93 | { 94 | "registry_name": "npm", 95 | "name": "test-mcp-server", 96 | "version": "1.0.$(date +%s)", 97 | "runtime_hint": "node", 98 | "runtime_arguments": [ 99 | { 100 | "type": "positional", 101 | "name": "config", 102 | "description": "Configuration file path", 103 | "format": "file_path", 104 | "is_required": false, 105 | "default": "./config.json" 106 | } 107 | ], 108 | "environment_variables": [ 109 | { 110 | "name": "PORT", 111 | "description": "Port to run the server on", 112 | "format": "number", 113 | "is_required": false, 114 | "default": "3000" 115 | }, 116 | { 117 | "name": "API_KEY", 118 | "description": "API key for external service", 119 | "format": "string", 120 | "is_required": true, 121 | "is_secret": true 122 | } 123 | ] 124 | } 125 | ] 126 | } 127 | EOF 128 | 129 | # Show the payload if verbose mode is enabled 130 | if $VERBOSE; then 131 | echo "Request Payload:" 132 | cat "$PAYLOAD_FILE" | jq '.' 133 | echo "-------------------------------------" 134 | fi 135 | 136 | # Test publish endpoint 137 | echo "Testing publish endpoint: $HOST/v0/publish" 138 | echo "Using Bearer Token: ${BEARER_TOKEN:0:10}..." # Show only first 10 chars for security 139 | 140 | # Get response and status code in a single request 141 | response_file=$(mktemp) 142 | headers_file=$(mktemp) 143 | 144 | # Execute curl with response body to file and headers+status to another file 145 | curl -s -X POST \ 146 | -H "Content-Type: application/json" \ 147 | -H "Authorization: Bearer ${BEARER_TOKEN}" \ 148 | -d "@$PAYLOAD_FILE" \ 149 | -D "$headers_file" \ 150 | -o "$response_file" \ 151 | "$HOST/v0/publish" 152 | 153 | # Read the response body 154 | http_response=$(<"$response_file") 155 | 156 | # Extract the status code from the headers file 157 | status_code=$(head -n1 "$headers_file" | awk '{print $2}') 158 | 159 | # Clean up temp files 160 | rm "$response_file" "$headers_file" 161 | 162 | echo "Status Code: $status_code" 163 | 164 | # Check for status code in 2xx range (200, 201, 202, etc) 165 | if [[ "${status_code:0:1}" == "2" ]]; then 166 | # Parse JSON response with jq 167 | echo "Response:" 168 | echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" 169 | 170 | # Check for server added message and extract UUID 171 | message=$(echo "$http_response" | jq -r '.message // empty' 2>/dev/null) 172 | server_id=$(echo "$http_response" | jq -r '.id // .server_id // empty' 2>/dev/null) 173 | 174 | # Validate the response contains success indicators 175 | success_indicators=0 176 | 177 | if [[ ! -z "$message" && "$message" != "null" ]]; then 178 | echo "✓ Success message received: $message" 179 | if [[ "$message" == *"server"* && ("$message" == *"added"* || "$message" == *"published"* || "$message" == *"created"*) ]]; then 180 | ((success_indicators++)) 181 | echo "✓ Message indicates server was successfully added" 182 | fi 183 | fi 184 | 185 | if [[ ! -z "$server_id" && "$server_id" != "null" && "$server_id" != "empty" ]]; then 186 | echo "✓ Server UUID received: $server_id" 187 | # Validate UUID format (basic check for UUID pattern) 188 | if [[ "$server_id" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then 189 | ((success_indicators++)) 190 | echo "✓ Server ID appears to be a valid UUID format" 191 | else 192 | echo "⚠ Server ID format may not be a standard UUID: $server_id" 193 | ((success_indicators++)) # Still count as success if we got an ID 194 | fi 195 | fi 196 | 197 | if [[ $success_indicators -ge 2 ]]; then 198 | echo "" 199 | echo "🎉 PUBLISH TEST PASSED!" 200 | echo " ✓ Server successfully published with ID: $server_id" 201 | echo " ✓ Success message: $message" 202 | else 203 | echo "" 204 | echo "❌ PUBLISH TEST FAILED!" 205 | echo " Expected: Success message about server being added AND a server UUID" 206 | echo " Received: message='$message', id='$server_id'" 207 | exit 1 208 | fi 209 | 210 | else 211 | echo "" 212 | echo "❌ PUBLISH TEST FAILED!" 213 | echo " Expected: 2xx status code" 214 | echo " Received: $status_code" 215 | echo " Response:" 216 | echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" 217 | exit 1 218 | fi 219 | 220 | echo "-------------------------------------" 221 | 222 | # Clean up temp file 223 | rm "$PAYLOAD_FILE" 224 | -------------------------------------------------------------------------------- /tools/publisher/README.md: -------------------------------------------------------------------------------- 1 | # MCP Registry Publisher Tool 2 | 3 | The MCP Registry Publisher Tool is designed to publish Model Context Protocol (MCP) server details to an MCP registry. This tool uses GitHub OAuth device flow authentication to securely manage the publishing process. 4 | 5 | ## Building the Tool 6 | 7 | You can build the publisher tool using the provided build script: 8 | 9 | ```bash 10 | # Build for current platform 11 | ./build.sh 12 | 13 | # Build for all supported platforms (optional) 14 | ./build.sh --all 15 | ``` 16 | 17 | The compiled binary will be placed in the `bin` directory. 18 | 19 | ## Usage 20 | 21 | ```bash 22 | # Basic usage 23 | ./bin/mcp-publisher -registry-url -mcp-file 24 | 25 | # Force a new login even if a token exists 26 | ./bin/mcp-publisher -registry-url -mcp-file -login 27 | ``` 28 | 29 | ### Command-line Arguments 30 | 31 | - `-registry-url`: URL of the MCP registry (required) 32 | - `-mcp-file`: Path to the MCP configuration file (required) 33 | - `-login`: Force a new GitHub authentication even if a token already exists (overwrites existing token file) 34 | - `-auth-method`: Authentication method to use (default: github-oauth) 35 | 36 | ## Authentication 37 | 38 | The tool has been simplified to use **GitHub OAuth device flow authentication exclusively**. Previous versions supported multiple authentication methods, but this version focuses solely on GitHub OAuth for better security and user experience. 39 | 40 | 1. **Automatic Setup**: The tool automatically retrieves the GitHub Client ID from the registry's health endpoint 41 | 2. **First Run Authentication**: When first run (or with the `--login` flag), the tool initiates the GitHub device flow 42 | 3. **User Authorization**: You'll be provided with a URL and a verification code to enter on GitHub 43 | 4. **Token Storage**: After successful authentication, the tool saves the access token locally in `.mcpregistry_token` for future use 44 | 5. **Secure Communication**: The token is sent in the HTTP Authorization header with the Bearer scheme for all registry API calls 45 | 46 | **Note**: Authentication is performed via GitHub OAuth App, which you must authorize for the respective resources (e.g., organization access if publishing organization repositories). 47 | 48 | ## Example 49 | 50 | 1. Prepare your `server.json` file with your server details: 51 | 52 | ```json 53 | { 54 | "name": "io.github.yourusername/your-repository", 55 | "description": "Your MCP server description", 56 | "version_detail": { 57 | "version": "1.0.0" 58 | }, 59 | "packages": [ 60 | { 61 | "registry_name": "npm", 62 | "name": "your-npm-package", 63 | "version": "1.0.0", 64 | "package_arguments": [ 65 | { 66 | "description": "Specify services and permissions", 67 | "is_required": true, 68 | "format": "string", 69 | "value": "-s", 70 | "default": "-s", 71 | "type": "positional", 72 | "value_hint": "-s" 73 | } 74 | ], 75 | "environment_variables": [ 76 | { 77 | "name": "API_KEY", 78 | "description": "API Key to access the server" 79 | } 80 | ] 81 | } 82 | ], 83 | "repository": { 84 | "url": "https://github.com/yourusername/your-repository", 85 | "source": "github" 86 | } 87 | } 88 | ``` 89 | 90 | 2. Run the publisher tool: 91 | 92 | ```bash 93 | ./bin/mcp-publisher --registry-url "https://mcp-registry.example.com" --mcp-file "./server.json" 94 | ``` 95 | 96 | 3. Follow the authentication instructions in the terminal if prompted. 97 | 98 | 4. Upon successful publication, you'll see a confirmation message. 99 | 100 | ## Important Notes 101 | 102 | - **GitHub Authentication Only**: The tool exclusively uses GitHub OAuth device flow for authentication 103 | - **Automatic Client ID**: The GitHub Client ID is automatically retrieved from the registry's health endpoint 104 | - **Token Storage**: The authentication token is saved in `.mcpregistry_token` in the current directory 105 | - **Internet Required**: Active internet connection needed for GitHub authentication and registry communication 106 | - **Repository Access**: Ensure the repository and package mentioned in your `server.json` file exist and are accessible 107 | - **OAuth Permissions**: You may need to grant the OAuth app access to your GitHub organizations if publishing org repositories 108 | -------------------------------------------------------------------------------- /tools/publisher/auth/README.md: -------------------------------------------------------------------------------- 1 | # Authentication System 2 | 3 | The publisher tool now uses an interface-based authentication system that allows for multiple authentication mechanisms. 4 | 5 | ## Architecture 6 | 7 | ### Provider Interface 8 | 9 | The `Provider` interface is defined in `auth/interface.go` and provides the following methods: 10 | 11 | - `GetToken(ctx context.Context) (string, error)` - Retrieves or generates an authentication token 12 | - `NeedsLogin() bool` - Checks if a new login flow is required 13 | - `Login(ctx context.Context) error` - Performs the authentication flow 14 | - `Name() string` - Returns the name of the authentication provider 15 | 16 | ### Available Authentication Providers 17 | 18 | #### 1. GitHub OAuth Provider 19 | - **Location**: `auth/github/oauth.go` 20 | - **Usage**: Uses GitHub's device flow for authentication 21 | - **Example**: `github.NewOAuthProvider(forceLogin, registryURL)` 22 | 23 | 24 | ## How to Add New Authentication Providers 25 | 26 | 1. Create a new package under `auth/` directory (e.g., `auth/custom/`) 27 | 2. Implement the `Provider` interface 28 | 3. Add any necessary configuration or initialization functions 29 | 4. Update the main application to use the new provider 30 | 31 | ### Example Implementation 32 | 33 | ```go 34 | package custom 35 | 36 | import ( 37 | "context" 38 | "fmt" 39 | ) 40 | 41 | type CustomProvider struct { 42 | // your custom fields 43 | } 44 | 45 | func NewCustomProvider(config string) *CustomProvider { 46 | return &CustomProvider{ 47 | // initialize your provider 48 | } 49 | } 50 | 51 | func (cp *CustomProvider) GetToken(ctx context.Context) (string, error) { 52 | // implement token retrieval logic 53 | return "custom-token", nil 54 | } 55 | 56 | func (cp *CustomProvider) NeedsLogin() bool { 57 | // implement login check logic 58 | return false 59 | } 60 | 61 | func (cp *CustomProvider) Login(ctx context.Context) error { 62 | // implement authentication flow 63 | return nil 64 | } 65 | 66 | func (cp *CustomProvider) Name() string { 67 | return "custom-auth" 68 | } 69 | ``` 70 | 71 | ## Usage in Main Application 72 | 73 | The main application automatically selects the appropriate authentication provider: 74 | 75 | 1. Uses `GitHub OAuth Provider` by default 76 | 2. Future providers can be added by extending the provider selection logic 77 | 78 | ```go 79 | // Create the appropriate auth provider based on configuration 80 | var authProvider auth.Provider 81 | switch authMethod { 82 | case "github-oauth": 83 | log.Println("Using GitHub OAuth for authentication") 84 | authProvider = github.NewOAuthProvider(forceLogin, registryURL) 85 | default: 86 | log.Printf("Unsupported authentication method: %s\n", authMethod) 87 | return 88 | } 89 | 90 | // Check if login is needed and perform authentication 91 | ctx := context.Background() 92 | if authProvider.NeedsLogin() { 93 | err := authProvider.Login(ctx) 94 | if err != nil { 95 | log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error()) 96 | return 97 | } 98 | } 99 | 100 | // Get the token 101 | token, err := authProvider.GetToken(ctx) 102 | ``` 103 | 104 | This design allows for easy extension and testing of different authentication mechanisms while maintaining a clean separation of concerns. 105 | -------------------------------------------------------------------------------- /tools/publisher/auth/github/oauth.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "os" 12 | "time" 13 | ) 14 | 15 | const ( 16 | tokenFilePath = ".mcpregistry_token" // #nosec:G101 17 | // GitHub OAuth URLs 18 | GitHubDeviceCodeURL = "https://github.com/login/device/code" // #nosec:G101 19 | GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101 20 | ) 21 | 22 | // DeviceCodeResponse represents the response from GitHub's device code endpoint 23 | type DeviceCodeResponse struct { 24 | DeviceCode string `json:"device_code"` 25 | UserCode string `json:"user_code"` 26 | VerificationURI string `json:"verification_uri"` 27 | ExpiresIn int `json:"expires_in"` 28 | Interval int `json:"interval"` 29 | } 30 | 31 | // AccessTokenResponse represents the response from GitHub's access token endpoint 32 | type AccessTokenResponse struct { 33 | AccessToken string `json:"access_token"` 34 | TokenType string `json:"token_type"` 35 | Scope string `json:"scope"` 36 | Error string `json:"error,omitempty"` 37 | } 38 | 39 | // OAuthProvider implements the AuthProvider interface using GitHub's device flow 40 | type OAuthProvider struct { 41 | clientID string 42 | forceLogin bool 43 | registryURL string 44 | } 45 | 46 | // ServerHealthResponse represents the response from the health endpoint 47 | type ServerHealthResponse struct { 48 | Status string `json:"status"` 49 | GitHubClientID string `json:"github_client_id"` 50 | } 51 | 52 | // NewOAuthProvider creates a new GitHub OAuth provider 53 | func NewOAuthProvider(forceLogin bool, registryURL string) *OAuthProvider { 54 | return &OAuthProvider{ 55 | forceLogin: forceLogin, 56 | registryURL: registryURL, 57 | } 58 | } 59 | 60 | // GetToken retrieves the GitHub access token 61 | func (g *OAuthProvider) GetToken(_ context.Context) (string, error) { 62 | return readToken() 63 | } 64 | 65 | // NeedsLogin checks if a new login is required 66 | func (g *OAuthProvider) NeedsLogin() bool { 67 | if g.forceLogin { 68 | return true 69 | } 70 | 71 | _, statErr := os.Stat(tokenFilePath) 72 | return os.IsNotExist(statErr) 73 | } 74 | 75 | // Login performs the GitHub device flow authentication 76 | func (g *OAuthProvider) Login(ctx context.Context) error { 77 | // If clientID is not set, try to retrieve it from the server's health endpoint 78 | if g.clientID == "" { 79 | clientID, err := getClientID(ctx, g.registryURL) 80 | if err != nil { 81 | return fmt.Errorf("error getting GitHub Client ID: %w", err) 82 | } 83 | g.clientID = clientID 84 | } 85 | 86 | // Device flow login logic using GitHub's device flow 87 | // First, request a device code 88 | deviceCode, userCode, verificationURI, err := g.requestDeviceCode(ctx) 89 | if err != nil { 90 | return fmt.Errorf("error requesting device code: %w", err) 91 | } 92 | 93 | // Display instructions to the user 94 | log.Println("\nTo authenticate, please:") 95 | log.Println("1. Go to:", verificationURI) 96 | log.Println("2. Enter code:", userCode) 97 | log.Println("3. Authorize this application") 98 | 99 | // Poll for the token 100 | log.Println("Waiting for authorization...") 101 | token, err := g.pollForToken(ctx, deviceCode) 102 | if err != nil { 103 | return fmt.Errorf("error polling for token: %w", err) 104 | } 105 | 106 | // Store the token locally 107 | err = saveToken(token) 108 | if err != nil { 109 | return fmt.Errorf("error saving token: %w", err) 110 | } 111 | 112 | log.Println("Successfully authenticated!") 113 | return nil 114 | } 115 | 116 | // Name returns the name of this auth provider 117 | func (g *OAuthProvider) Name() string { 118 | return "github-oauth" 119 | } 120 | 121 | // requestDeviceCode initiates the device authorization flow 122 | func (g *OAuthProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) { 123 | if g.clientID == "" { 124 | return "", "", "", fmt.Errorf("GitHub Client ID is required for device flow login") 125 | } 126 | 127 | payload := map[string]string{ 128 | "client_id": g.clientID, 129 | "scope": "read:org read:user", 130 | } 131 | 132 | jsonData, err := json.Marshal(payload) 133 | if err != nil { 134 | return "", "", "", err 135 | } 136 | 137 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubDeviceCodeURL, bytes.NewBuffer(jsonData)) 138 | if err != nil { 139 | return "", "", "", err 140 | } 141 | req.Header.Set("Content-Type", "application/json") 142 | req.Header.Set("Accept", "application/json") 143 | 144 | client := &http.Client{} 145 | resp, err := client.Do(req) 146 | if err != nil { 147 | return "", "", "", err 148 | } 149 | defer resp.Body.Close() 150 | 151 | body, err := io.ReadAll(resp.Body) 152 | if err != nil { 153 | return "", "", "", err 154 | } 155 | 156 | if resp.StatusCode != http.StatusOK { 157 | return "", "", "", fmt.Errorf("request device code failed: %s", body) 158 | } 159 | 160 | var deviceCodeResp DeviceCodeResponse 161 | err = json.Unmarshal(body, &deviceCodeResp) 162 | if err != nil { 163 | return "", "", "", err 164 | } 165 | 166 | return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil 167 | } 168 | 169 | // pollForToken polls for access token after user completes authorization 170 | func (g *OAuthProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) { 171 | if g.clientID == "" { 172 | return "", fmt.Errorf("GitHub Client ID is required for device flow login") 173 | } 174 | 175 | payload := map[string]string{ 176 | "client_id": g.clientID, 177 | "device_code": deviceCode, 178 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 179 | } 180 | 181 | jsonData, err := json.Marshal(payload) 182 | if err != nil { 183 | return "", err 184 | } 185 | 186 | // Default polling interval and expiration time 187 | interval := 5 // seconds 188 | expiresIn := 900 // 15 minutes 189 | deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) 190 | 191 | for time.Now().Before(deadline) { 192 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData)) 193 | if err != nil { 194 | return "", err 195 | } 196 | req.Header.Set("Content-Type", "application/json") 197 | req.Header.Set("Accept", "application/json") 198 | 199 | client := &http.Client{} 200 | resp, err := client.Do(req) 201 | if err != nil { 202 | return "", err 203 | } 204 | 205 | body, err := io.ReadAll(resp.Body) 206 | resp.Body.Close() 207 | if err != nil { 208 | return "", err 209 | } 210 | 211 | var tokenResp AccessTokenResponse 212 | err = json.Unmarshal(body, &tokenResp) 213 | if err != nil { 214 | return "", err 215 | } 216 | 217 | if tokenResp.Error == "authorization_pending" { 218 | // User hasn't authorized yet, wait and retry 219 | time.Sleep(time.Duration(interval) * time.Second) 220 | continue 221 | } 222 | 223 | if tokenResp.Error != "" { 224 | return "", fmt.Errorf("token request failed: %s", tokenResp.Error) 225 | } 226 | 227 | if tokenResp.AccessToken != "" { 228 | return tokenResp.AccessToken, nil 229 | } 230 | 231 | // If we reach here, something unexpected happened 232 | return "", fmt.Errorf("failed to obtain access token") 233 | } 234 | 235 | return "", fmt.Errorf("device code authorization timed out") 236 | } 237 | 238 | // saveToken saves the GitHub access token to a local file 239 | func saveToken(token string) error { 240 | return os.WriteFile(tokenFilePath, []byte(token), 0600) 241 | } 242 | 243 | // readToken reads the GitHub access token from a local file 244 | func readToken() (string, error) { 245 | tokenData, err := os.ReadFile(tokenFilePath) 246 | if err != nil { 247 | return "", err 248 | } 249 | return string(tokenData), nil 250 | } 251 | 252 | func getClientID(ctx context.Context, registryURL string) (string, error) { 253 | // This function should retrieve the GitHub Client ID from the registry URL 254 | // For now, we will return a placeholder value 255 | // In a real implementation, this would likely involve querying the registry or configuration 256 | if registryURL == "" { 257 | return "", fmt.Errorf("registry URL is required to get GitHub Client ID") 258 | } 259 | // get the clientID from the server's health endpoint 260 | healthURL := registryURL + "/v0/health" 261 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) 262 | if err != nil { 263 | log.Printf("Error creating request: %s\n", err.Error()) 264 | return "", err 265 | } 266 | 267 | client := &http.Client{} 268 | resp, err := client.Do(req) 269 | if err != nil { 270 | log.Printf("Error fetching health endpoint: %s\n", err.Error()) 271 | return "", err 272 | } 273 | defer resp.Body.Close() 274 | if resp.StatusCode != http.StatusOK { 275 | body, _ := io.ReadAll(resp.Body) 276 | log.Printf("Health endpoint returned status %d: %s\n", resp.StatusCode, body) 277 | return "", fmt.Errorf("health endpoint returned status %d: %s", resp.StatusCode, body) 278 | } 279 | 280 | var healthResponse ServerHealthResponse 281 | err = json.NewDecoder(resp.Body).Decode(&healthResponse) 282 | if err != nil { 283 | log.Printf("Error decoding health response: %s\n", err.Error()) 284 | return "", err 285 | } 286 | if healthResponse.GitHubClientID == "" { 287 | log.Println("GitHub Client ID is not set in the server's health response.") 288 | return "", fmt.Errorf("GitHub Client ID is not set in the server's health response") 289 | } 290 | 291 | githubClientID := healthResponse.GitHubClientID 292 | 293 | return githubClientID, nil 294 | } 295 | -------------------------------------------------------------------------------- /tools/publisher/auth/interface.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "context" 4 | 5 | // Provider defines the interface for authentication mechanisms 6 | type Provider interface { 7 | // GetToken retrieves or generates an authentication token 8 | // It returns the token string and any error encountered 9 | GetToken(ctx context.Context) (string, error) 10 | 11 | // NeedsLogin checks if a new login is required 12 | // This can check for existing tokens, expiry, etc. 13 | NeedsLogin() bool 14 | 15 | // Login performs the authentication flow 16 | // This might involve user interaction, device flows, etc. 17 | Login(ctx context.Context) error 18 | 19 | // Name returns the name of the authentication provider 20 | Name() string 21 | } 22 | -------------------------------------------------------------------------------- /tools/publisher/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build script for MCP Registry Publisher Tool 4 | 5 | # Set variables 6 | OUTPUT_DIR="./bin" 7 | BINARY_NAME="mcp-publisher" 8 | 9 | # Create output directory if it doesn't exist 10 | mkdir -p $OUTPUT_DIR 11 | 12 | # Print build information 13 | echo "Building MCP Registry Publisher Tool..." 14 | 15 | # Build for current platform 16 | echo "Building for $(go env GOOS)/$(go env GOARCH)..." 17 | go build -o "$OUTPUT_DIR/$BINARY_NAME" . 18 | 19 | # Make the binary executable 20 | chmod +x "$OUTPUT_DIR/$BINARY_NAME" 21 | 22 | echo "Build complete: $OUTPUT_DIR/$BINARY_NAME" 23 | 24 | # Optional: Build for multiple platforms 25 | if [ "$1" == "--all" ]; then 26 | echo "Building for all supported platforms..." 27 | 28 | # Linux AMD64 29 | GOOS=linux GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-linux-amd64" . 30 | 31 | # MacOS AMD64 32 | GOOS=darwin GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-darwin-amd64" . 33 | 34 | # MacOS ARM64 (Apple Silicon) 35 | GOOS=darwin GOARCH=arm64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-darwin-arm64" . 36 | 37 | # Windows AMD64 38 | GOOS=windows GOARCH=amd64 go build -o "$OUTPUT_DIR/${BINARY_NAME}-windows-amd64.exe" . 39 | 40 | echo "Multi-platform build complete in $OUTPUT_DIR/" 41 | fi 42 | -------------------------------------------------------------------------------- /tools/publisher/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "os" 13 | "strings" 14 | 15 | "github.com/modelcontextprotocol/registry/tools/publisher/auth" 16 | "github.com/modelcontextprotocol/registry/tools/publisher/auth/github" 17 | ) 18 | 19 | func main() { 20 | var registryURL string 21 | var mcpFilePath string 22 | var forceLogin bool 23 | var authMethod string 24 | 25 | // Command-line flags for configuration 26 | flag.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") 27 | flag.StringVar(&mcpFilePath, "mcp-file", "", "path to the MCP file (required)") 28 | flag.BoolVar(&forceLogin, "login", false, "force a new login even if a token exists") 29 | flag.StringVar(&authMethod, "auth-method", "github-oauth", "authentication method to use (default: github-oauth)") 30 | 31 | flag.Parse() 32 | 33 | if registryURL == "" || mcpFilePath == "" { 34 | flag.Usage() 35 | return 36 | } 37 | 38 | // Read MCP file 39 | mcpData, err := os.ReadFile(mcpFilePath) 40 | if err != nil { 41 | log.Printf("Error reading MCP file: %s\n", err.Error()) 42 | return 43 | } 44 | 45 | var authProvider auth.Provider // Determine the authentication method 46 | switch authMethod { 47 | case "github-oauth": 48 | log.Println("Using GitHub OAuth for authentication") 49 | authProvider = github.NewOAuthProvider(forceLogin, registryURL) 50 | default: 51 | log.Printf("Unsupported authentication method: %s\n", authMethod) 52 | return 53 | } 54 | 55 | // Check if login is needed and perform authentication 56 | ctx := context.Background() 57 | if authProvider.NeedsLogin() { 58 | err := authProvider.Login(ctx) 59 | if err != nil { 60 | log.Printf("Failed to authenticate with %s: %s\n", authProvider.Name(), err.Error()) 61 | return 62 | } 63 | } 64 | 65 | // Get the token 66 | token, err := authProvider.GetToken(ctx) 67 | if err != nil { 68 | log.Printf("Error getting token from %s: %s\n", authProvider.Name(), err.Error()) 69 | return 70 | } 71 | 72 | // Publish to registry 73 | err = publishToRegistry(registryURL, mcpData, token) 74 | if err != nil { 75 | log.Printf("Failed to publish to registry: %s\n", err.Error()) 76 | return 77 | } 78 | 79 | log.Println("Successfully published to registry!") 80 | } 81 | 82 | // publishToRegistry sends the MCP server details to the registry with authentication 83 | func publishToRegistry(registryURL string, mcpData []byte, token string) error { 84 | // Parse the MCP JSON data 85 | var mcpDetails map[string]interface{} 86 | err := json.Unmarshal(mcpData, &mcpDetails) 87 | if err != nil { 88 | return fmt.Errorf("error parsing server.json file: %w", err) 89 | } 90 | 91 | // Create the publish request payload (without authentication) 92 | publishReq := mcpDetails 93 | 94 | // Convert the request to JSON 95 | jsonData, err := json.Marshal(publishReq) 96 | if err != nil { 97 | return fmt.Errorf("error serializing request: %w", err) 98 | } 99 | 100 | // Ensure the URL ends with the publish endpoint 101 | if !strings.HasSuffix(registryURL, "/") { 102 | registryURL += "/" 103 | } 104 | publishURL := registryURL + "v0/publish" 105 | 106 | // Create and send the request 107 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, publishURL, bytes.NewBuffer(jsonData)) 108 | if err != nil { 109 | return fmt.Errorf("error creating request: %w", err) 110 | } 111 | req.Header.Set("Content-Type", "application/json") 112 | req.Header.Set("Authorization", "Bearer "+token) 113 | 114 | client := &http.Client{} 115 | resp, err := client.Do(req) 116 | if err != nil { 117 | return fmt.Errorf("error sending request: %w", err) 118 | } 119 | defer resp.Body.Close() 120 | 121 | // Read and check the response 122 | body, err := io.ReadAll(resp.Body) 123 | if err != nil { 124 | return fmt.Errorf("error reading response: %w", err) 125 | } 126 | 127 | if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { 128 | return fmt.Errorf("publication failed with status %d: %s", resp.StatusCode, body) 129 | } 130 | 131 | log.Println(string(body)) 132 | return nil 133 | } 134 | -------------------------------------------------------------------------------- /tools/publisher/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "name": "io.github./", 4 | "packages": [ 5 | { 6 | "registry_name": "npm", 7 | "name": "io.github./", 8 | "version": "0.2.23", 9 | "package_arguments": [ 10 | { 11 | "description": "Specify services and permissions.", 12 | "is_required": true, 13 | "format": "string", 14 | "value": "-s", 15 | "default": "-s", 16 | "type": "positional", 17 | "value_hint": "-s" 18 | } 19 | ], 20 | "environment_variables": [ 21 | { 22 | "description": "API Key to access the server", 23 | "name": "API_KEY" 24 | } 25 | ] 26 | },{ 27 | "registry_name": "docker>", 28 | "name": "io.github./-cli", 29 | "version": "0.123.223", 30 | "runtime_hint": "docker", 31 | "runtime_arguments": [ 32 | { 33 | "description": "Specify services and permissions.", 34 | "is_required": true, 35 | "format": "string", 36 | "value": "--mount", 37 | "default": "--mount", 38 | "type": "positional", 39 | "value_hint": "--mount" 40 | } 41 | ], 42 | "environment_variables": [ 43 | { 44 | "description": "API Key to access the server", 45 | "name": "API_KEY" 46 | } 47 | ] 48 | } 49 | ], 50 | "repository": { 51 | "url": "https://github.com//", 52 | "source": "github" 53 | }, 54 | "version_detail": { 55 | "version": "0.0.1-" 56 | } 57 | } 58 | --------------------------------------------------------------------------------