Changes on the server.json format and the API (#562)
This commit is contained in:
parent
4962609697
commit
b21c6f564d
46 changed files with 4056 additions and 5608 deletions
5
Makefile
5
Makefile
|
|
@ -59,13 +59,16 @@ lint-fix: ## Run linter with auto-fix (includes formatting)
|
|||
golangci-lint run --fix --timeout=5m
|
||||
|
||||
# Combined targets
|
||||
check: lint validate test-all ## Run all checks (lint, validate, unit tests)
|
||||
check: dev-down lint validate test-all ## Run all checks (lint, validate, unit tests) and ensure dev environment is down
|
||||
@echo "All checks passed!"
|
||||
|
||||
# Development targets
|
||||
dev-compose: ## Start development environment with Docker Compose (builds image automatically)
|
||||
docker compose up --build
|
||||
|
||||
dev-down: ## Stop development environment
|
||||
docker compose down
|
||||
|
||||
# Cleanup
|
||||
clean: ## Clean build artifacts and coverage files
|
||||
rm -rf bin
|
||||
|
|
|
|||
|
|
@ -301,10 +301,9 @@ func createServerJSON(
|
|||
|
||||
// Create server structure
|
||||
return apiv0.ServerJSON{
|
||||
Schema: "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
Schema: "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
Name: name,
|
||||
Description: description,
|
||||
Status: model.StatusActive,
|
||||
Repository: model.Repository{
|
||||
URL: repoURL,
|
||||
Source: repoSource,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func PublishCommand(args []string) error {
|
|||
|
||||
// Check for deprecated schema and recommend migration
|
||||
// Allow empty schema (will use default) but reject old schemas
|
||||
if serverJSON.Schema != "" && !strings.Contains(serverJSON.Schema, "2025-09-16") {
|
||||
if serverJSON.Schema != "" && !strings.Contains(serverJSON.Schema, "2025-09-29") {
|
||||
return fmt.Errorf(`deprecated schema detected :%s.
|
||||
|
||||
Migrate to the current schema format for new servers.
|
||||
|
|
@ -82,14 +82,12 @@ Migrate to the current schema format for new servers.
|
|||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully published")
|
||||
if serverID := response.GetServerID(); serverID != "" {
|
||||
_, _ = fmt.Fprintf(os.Stdout, "✓ Server Id %s version %s\n", serverID, response.Version)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "✓ Server %s version %s\n", response.Server.Name, response.Server.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishToRegistry(registryURL string, serverData []byte, token string) (*apiv0.ServerJSON, error) {
|
||||
func publishToRegistry(registryURL string, serverData []byte, token string) (*apiv0.ServerResponse, error) {
|
||||
// Parse the server JSON data
|
||||
var serverJSON apiv0.ServerJSON
|
||||
err := json.Unmarshal(serverData, &serverJSON)
|
||||
|
|
@ -134,9 +132,10 @@ func publishToRegistry(registryURL string, serverData []byte, token string) (*ap
|
|||
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &serverJSON); err != nil {
|
||||
var serverResponse apiv0.ServerResponse
|
||||
if err := json.Unmarshal(body, &serverResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &serverJSON, nil
|
||||
return &serverResponse, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ func TestPublishCommand_DeprecatedSchema(t *testing.T) {
|
|||
errorSubstr: "deprecated schema detected",
|
||||
},
|
||||
{
|
||||
name: "current 2025-09-16 schema should pass validation",
|
||||
schema: "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
name: "current 2025-09-29 schema should pass validation",
|
||||
schema: "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -69,7 +69,6 @@ func TestPublishCommand_DeprecatedSchema(t *testing.T) {
|
|||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Status: model.StatusActive,
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/test",
|
||||
Source: "github",
|
||||
|
|
|
|||
|
|
@ -84,11 +84,9 @@ func main() {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
importerService := importer.NewService(db)
|
||||
importerService := importer.NewService(registryService)
|
||||
if err := importerService.ImportFromPath(ctx, cfg.SeedFrom); err != nil {
|
||||
log.Printf("Failed to import seed data: %v", err)
|
||||
} else {
|
||||
log.Println("Data import completed successfully")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
4312
data/seed.json
4312
data/seed.json
File diff suppressed because it is too large
Load diff
|
|
@ -23,14 +23,17 @@ Use this when you need to modify details of a specific version (e.g., fix descri
|
|||
### Step 1: Download Specific Version
|
||||
|
||||
```bash
|
||||
export SERVER_ID="<server-uuid>" # This is the server ID (consistent across versions)
|
||||
export VERSION="<version-string>" # e.g., "1.0.0" (optional, defaults to latest)
|
||||
export SERVER_NAME="<server-name>" # e.g., "com.example/my-server"
|
||||
export VERSION="<version-string>" # e.g., "1.0.0" (optional, defaults to latest)
|
||||
|
||||
# URL encode the server name (replace / with %2F)
|
||||
ENCODED_SERVER_NAME=$(echo "$SERVER_NAME" | sed 's|/|%2F|g')
|
||||
|
||||
# Get specific version
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${SERVER_ID}?version=${VERSION}" > server.json
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}/versions/${VERSION}" > server.json
|
||||
|
||||
# Or get latest version (omit version parameter)
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${SERVER_ID}" > server.json
|
||||
# Or get latest version (omit /versions/VERSION)
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}" > server.json
|
||||
```
|
||||
|
||||
### Step 2: Make Changes
|
||||
|
|
@ -41,7 +44,7 @@ Open `server.json` and edit the specific version details. You cannot change the
|
|||
|
||||
```bash
|
||||
# Update specific version
|
||||
curl -X PUT "https://registry.modelcontextprotocol.io/v0/servers/${SERVER_ID}?version=${VERSION}" \
|
||||
curl -X PUT "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}/versions/${VERSION}" \
|
||||
-H "Authorization: Bearer ${REGISTRY_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(cat server.json)"
|
||||
|
|
@ -55,36 +58,35 @@ Use this when you need to apply changes across all versions of a server (e.g., m
|
|||
### Step 1: List All Versions
|
||||
|
||||
```bash
|
||||
export SERVER_ID="<server-uuid>" # This is the server ID (consistent across versions)
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${SERVER_ID}/versions" > all_versions.json
|
||||
export SERVER_NAME="<server-name>" # e.g., "com.example/my-server"
|
||||
ENCODED_SERVER_NAME=$(echo "$SERVER_NAME" | sed 's|/|%2F|g')
|
||||
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}/versions" > all_versions.json
|
||||
```
|
||||
|
||||
### Step 2: Extract Version IDs
|
||||
### Step 2: Extract Versions
|
||||
|
||||
```bash
|
||||
# Extract all version IDs from the server
|
||||
jq -r '.servers[]._meta."io.modelcontextprotocol.registry/official".version_id' all_versions.json > version_ids.txt
|
||||
# Extract all versions from the server
|
||||
jq -r '.servers[].server.version' all_versions.json > versions.txt
|
||||
```
|
||||
|
||||
### Step 3: Apply Changes to All Versions
|
||||
|
||||
```bash
|
||||
# For each version, download, modify, and update
|
||||
while read VERSION_ID; do
|
||||
echo "Processing version: $VERSION_ID"
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${VERSION_ID}" > temp_version.json
|
||||
# For each version, apply changes using the edit endpoint
|
||||
while read VERSION; do
|
||||
echo "Processing version: $VERSION"
|
||||
|
||||
# Apply your changes here (e.g., set status to "deleted")
|
||||
jq '.status = "deleted"' temp_version.json > modified_version.json
|
||||
|
||||
curl -X PUT "https://registry.modelcontextprotocol.io/v0/servers/${VERSION_ID}" \
|
||||
curl -X PUT "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}/versions/${VERSION}?status=deleted" \
|
||||
-H "Authorization: Bearer ${REGISTRY_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"server\": $(cat modified_version.json)}"
|
||||
done < version_ids.txt
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
done < versions.txt
|
||||
|
||||
# Clean up temporary files
|
||||
rm temp_version.json modified_version.json version_ids.txt all_versions.json
|
||||
rm versions.txt all_versions.json
|
||||
```
|
||||
|
||||
## Quick Operations
|
||||
|
|
@ -92,29 +94,47 @@ rm temp_version.json modified_version.json version_ids.txt all_versions.json
|
|||
### Get Latest Version of a Server
|
||||
|
||||
```bash
|
||||
export SERVER_ID="<server-uuid>"
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${SERVER_ID}" > latest_version.json
|
||||
export VERSION_ID=$(jq -r '._meta."io.modelcontextprotocol.registry/official".version_id' latest_version.json)
|
||||
echo "Latest version ID: $VERSION_ID"
|
||||
export SERVER_NAME="<server-name>" # e.g., "com.example/my-server"
|
||||
ENCODED_SERVER_NAME=$(echo "$SERVER_NAME" | sed 's|/|%2F|g')
|
||||
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}" > latest_version.json
|
||||
export VERSION=$(jq -r '.server.version' latest_version.json)
|
||||
echo "Latest version: $VERSION"
|
||||
```
|
||||
|
||||
### Takedown a Specific Version
|
||||
|
||||
```bash
|
||||
export VERSION_ID="<version-uuid>"
|
||||
./tools/admin/takedown.sh
|
||||
export SERVER_NAME="<server-name>" # e.g., "com.example/my-server"
|
||||
export VERSION="<version-string>" # e.g., "1.0.0"
|
||||
export REGISTRY_TOKEN="<your-token>"
|
||||
|
||||
REGISTRY_TOKEN="$REGISTRY_TOKEN" SERVER_NAME="$SERVER_NAME" VERSION="$VERSION" ./tools/admin/takedown.sh
|
||||
```
|
||||
|
||||
### Takedown an Entire Server (All Versions)
|
||||
### Takedown Latest Version (Entire Server)
|
||||
|
||||
```bash
|
||||
export SERVER_ID="<server-uuid>"
|
||||
# Get all version IDs and takedown each one
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${SERVER_ID}/versions" | \
|
||||
jq -r '.servers[]._meta."io.modelcontextprotocol.registry/official".version_id' | \
|
||||
while read VERSION_ID; do
|
||||
echo "Taking down version: $VERSION_ID"
|
||||
./tools/admin/takedown.sh "$VERSION_ID"
|
||||
export SERVER_NAME="<server-name>" # e.g., "com.example/my-server"
|
||||
export REGISTRY_TOKEN="<your-token>"
|
||||
|
||||
# This marks the latest version as deleted
|
||||
REGISTRY_TOKEN="$REGISTRY_TOKEN" SERVER_NAME="$SERVER_NAME" ./tools/admin/takedown.sh
|
||||
```
|
||||
|
||||
### Takedown All Versions of a Server
|
||||
|
||||
```bash
|
||||
export SERVER_NAME="<server-name>" # e.g., "com.example/my-server"
|
||||
export REGISTRY_TOKEN="<your-token>"
|
||||
ENCODED_SERVER_NAME=$(echo "$SERVER_NAME" | sed 's|/|%2F|g')
|
||||
|
||||
# Get all versions and takedown each one
|
||||
curl -s "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED_SERVER_NAME}/versions" | \
|
||||
jq -r '.servers[].server.version' | \
|
||||
while read VERSION; do
|
||||
echo "Taking down version: $VERSION"
|
||||
REGISTRY_TOKEN="$REGISTRY_TOKEN" SERVER_NAME="$SERVER_NAME" VERSION="$VERSION" ./tools/admin/takedown.sh
|
||||
done
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ Integration patterns and best practices for building applications that consume M
|
|||
**Authentication**: Not required for read-only access
|
||||
|
||||
- **`GET /v0/servers`** - List all servers with pagination
|
||||
- **`GET /v0/servers/{server_id}`** - Get latest version of server by server ID
|
||||
- **`GET /v0/servers/{server_id}?version=X.X.X`** - Get specific version of server
|
||||
- **`GET /v0/servers/{server_id}/versions`** - List all versions of a server
|
||||
- **`GET /v0/servers/{serverName}`** - Get latest version of server by server name (URL-encoded)
|
||||
- **`GET /v0/servers/{serverName}/versions/{version}`** - Get specific version of server
|
||||
- **`GET /v0/servers/{serverName}/versions`** - List all versions of a server
|
||||
|
||||
See the [interactive API documentation](https://registry.modelcontextprotocol.io/docs) for complete request/response schemas.
|
||||
|
||||
|
|
@ -22,6 +22,32 @@ See the [interactive API documentation](https://registry.modelcontextprotocol.io
|
|||
|
||||
For now we recommend scraping the `GET /v0/servers` endpoint on some regular basis. In the future we might provide a filter for updatedAt ([#291](https://github.com/modelcontextprotocol/registry/issues/291)) to get only recently changed servers.
|
||||
|
||||
### Pagination Example
|
||||
|
||||
The API uses cursor-based pagination. Here's how to fetch all servers:
|
||||
|
||||
```bash
|
||||
# Initial request
|
||||
curl "https://registry.modelcontextprotocol.io/v0/servers?limit=100"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": [...],
|
||||
"metadata": {
|
||||
"count": 100,
|
||||
"nextCursor": "com.example/my-server:1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Next page using cursor
|
||||
curl "https://registry.modelcontextprotocol.io/v0/servers?limit=100&cursor=com.example%2Fmy-server%3A1.0.0"
|
||||
```
|
||||
|
||||
**Important**: Always URL-encode cursor values when using them in query parameters.
|
||||
|
||||
Servers are generally immutable, except for the `status` field which can be updated to `deleted` (among other states). For these packages, we recommend you also update the status field to `deleted` or remove the package from your registry quickly. This is because this status generally indicates it has violated our permissive [moderation guidelines](../administration/moderation-guidelines.md), suggesting it is illegal, malware or spam.
|
||||
|
||||
### Filtering & Enhancement
|
||||
|
|
@ -32,7 +58,7 @@ You can also add custom metadata to servers using the `_meta` field. For example
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.yourname/weather-server",
|
||||
"description": "MCP server for weather data access",
|
||||
"status": "active",
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ This creates a `server.json` with auto-detected values. You'll see something lik
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.yourname/your-server",
|
||||
"description": "A description of your MCP server",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -151,7 +151,7 @@ Add an `mcpName` field to your `package.json`:
|
|||
### Example server.json
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.username/server-name",
|
||||
"description": "A server that provides npm package functionality",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -189,7 +189,7 @@ Add it to your README.md file (which becomes the package description on PyPI). T
|
|||
### Example server.json
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.username/server-name",
|
||||
"description": "A server that provides PyPI package functionality",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -227,7 +227,7 @@ Add a README file to your NuGet package that includes the server name. This can
|
|||
### Example server.json
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.username/server-name",
|
||||
"description": "A server that provides NuGet package functionality",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -269,7 +269,7 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
|
|||
### Example server.json (Docker Hub)
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.username/server-name",
|
||||
"description": "A server that provides Docker container functionality",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -290,7 +290,7 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
|
|||
### Example server.json (GitHub Container Registry)
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.username/server-name",
|
||||
"description": "A server that provides GitHub container functionality",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -332,7 +332,7 @@ openssl dgst -sha256 server.mcpb
|
|||
### Example server.json
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.username/server-name",
|
||||
"description": "A server that provides MCPB package functionality",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -377,7 +377,7 @@ Add the `remotes` field to your `server.json` (can coexist with `packages`):
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "com.yourcompany/api-server",
|
||||
"description": "Cloud-hosted MCP server for API operations",
|
||||
"version": "2.0.0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,30 @@
|
|||
|
||||
Changes to the REST API endpoints and responses.
|
||||
|
||||
## 2025-09-29
|
||||
|
||||
### ⚠️ BREAKING CHANGES
|
||||
|
||||
#### API Endpoint Restructuring
|
||||
|
||||
API endpoints updated to use server names instead of server IDs for better usability.
|
||||
|
||||
**Changed endpoints:**
|
||||
- `GET /v0/servers/{server_id}` → `GET /v0/servers/{serverName}`
|
||||
- `GET /v0/servers/{server_id}/versions` → `GET /v0/servers/{serverName}/versions`
|
||||
|
||||
**New endpoints:**
|
||||
- `GET /v0/servers/{serverName}/versions/{version}` - Get specific server version
|
||||
- `PUT /v0/servers/{serverName}/versions/{version}` - Edit server version (admin only)
|
||||
|
||||
**Response format changes:**
|
||||
- Introduced `ServerResponse` schema separating server data from registry metadata
|
||||
- Moved `status` field from server data to `_meta.io.modelcontextprotocol.registry/official`
|
||||
- Removed `io.modelcontextprotocol.registry/official` metadata from `ServerDetail` schema
|
||||
|
||||
### Changed
|
||||
- OpenAPI spec version: `2025-09-16` → `2025-09-29`
|
||||
|
||||
## 2025-09-16
|
||||
|
||||
### ⚠️ BREAKING CHANGES
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ The official registry has some more endpoints and restrictions on top of this. S
|
|||
|
||||
### Core Endpoints
|
||||
- **`GET /v0/servers`** - List all servers with pagination
|
||||
- **`GET /v0/servers/{server_id}`** - Get latest version of server by server ID
|
||||
- **`GET /v0/servers/{server_id}?version=X.X.X`** - Get specific version of server
|
||||
- **`GET /v0/servers/{server_id}/versions`** - List all versions of a server
|
||||
- **`GET /v0/servers/{serverName}`** - Get latest version of server by server name (URL-encoded)
|
||||
- **`GET /v0/servers/{serverName}/versions/{version}`** - Get specific version of server (both parameters should be URL-encoded)
|
||||
- **`GET /v0/servers/{serverName}/versions`** - List all versions of a server
|
||||
- **`POST /v0/publish`** - Publish new server (optional, registry-specific authentication)
|
||||
|
||||
### Authentication
|
||||
|
|
@ -27,6 +27,16 @@ The official registry has some more endpoints and restrictions on top of this. S
|
|||
### Content Type
|
||||
All requests and responses use `application/json`
|
||||
|
||||
### Pagination
|
||||
List endpoints use cursor-based pagination for efficient, stable results.
|
||||
|
||||
#### Usage
|
||||
1. **Initial request**: Omit the `cursor` parameter
|
||||
2. **Subsequent requests**: Use the `nextCursor` value from the previous response
|
||||
3. **End of results**: When `nextCursor` is null or empty, there are no more results
|
||||
|
||||
**Important**: Always treat cursors as opaque strings. Never manually construct or modify cursor values.
|
||||
|
||||
### Basic Example: List Servers
|
||||
|
||||
```bash
|
||||
|
|
@ -37,15 +47,23 @@ curl https://registry.example.com/v0/servers?limit=10
|
|||
{
|
||||
"servers": [
|
||||
{
|
||||
"name": "io.modelcontextprotocol/filesystem",
|
||||
"description": "Filesystem operations server",
|
||||
"status": "active",
|
||||
"version": "1.0.2"
|
||||
"server": {
|
||||
"name": "io.modelcontextprotocol/filesystem",
|
||||
"description": "Filesystem operations server",
|
||||
"version": "1.0.2"
|
||||
},
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol.registry/official": {
|
||||
"status": "active",
|
||||
"publishedAt": "2025-01-01T10:30:00Z",
|
||||
"isLatest": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"count": 10,
|
||||
"next_cursor": "eyJ..."
|
||||
"nextCursor": "com.example/my-server:1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -58,4 +58,4 @@ Example: `GET /v0/servers?search=filesystem&updated_since=2025-08-01T00:00:00Z&v
|
|||
#### Admin endpoints
|
||||
- GET `/metrics` - Prometheus metrics endpoint
|
||||
- GET `/v0/health` - Basic health check endpoint
|
||||
- PUT `/v0/servers/{server_id}?version=X.X.X` - Edit specific server version (version parameter required)
|
||||
- PUT `/v0/servers/{serverName}/versions/{version}` - Edit specific server version
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
openapi: 3.1.0
|
||||
jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"
|
||||
$id: https://modelcontextprotocol.io/schemas/draft/2025-09-16/server-registry-openapi
|
||||
$id: https://modelcontextprotocol.io/schemas/draft/2025-09-29/server-registry-openapi
|
||||
info:
|
||||
title: MCP Server Registry API
|
||||
version: "2025-09-16"
|
||||
version: "2025-09-29"
|
||||
summary: API for discovering and accessing MCP server metadata
|
||||
description: |
|
||||
Specification for a theoretical REST API that serves up metadata about MCP servers.
|
||||
|
|
@ -19,7 +19,10 @@ paths:
|
|||
parameters:
|
||||
- name: cursor
|
||||
in: query
|
||||
description: Pagination cursor for retrieving next set of results
|
||||
description: |
|
||||
Pagination cursor for retrieving next set of results.
|
||||
|
||||
Cursors are opaque strings returned in the `metadata.nextCursor` field of paginated responses. Always use the exact cursor value returned by the API.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
|
|
@ -36,30 +39,25 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerList'
|
||||
/v0/servers/{server_id}:
|
||||
/v0/servers/{serverName}:
|
||||
get:
|
||||
summary: Get MCP server details
|
||||
description: Returns detailed information about a specific MCP server. Returns latest version by default, or specific version if version query parameter is provided.
|
||||
description: Returns detailed information about the latest version of a specific MCP server.
|
||||
parameters:
|
||||
- name: server_id
|
||||
- name: serverName
|
||||
in: path
|
||||
required: true
|
||||
description: Unique server ID (consistent across all versions)
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: version
|
||||
in: query
|
||||
description: Specific version to retrieve (e.g., "1.0.0"). If not provided, returns latest version.
|
||||
description: URL-encoded server name (e.g., "com.example%2Fmy-server")
|
||||
schema:
|
||||
type: string
|
||||
example: "com.example%2Fmy-server"
|
||||
responses:
|
||||
'200':
|
||||
description: Detailed server information
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerDetail'
|
||||
$ref: '#/components/schemas/ServerResponse'
|
||||
'404':
|
||||
description: Server not found
|
||||
content:
|
||||
|
|
@ -70,18 +68,18 @@ paths:
|
|||
error:
|
||||
type: string
|
||||
example: "Server not found"
|
||||
/v0/servers/{server_id}/versions:
|
||||
/v0/servers/{serverName}/versions:
|
||||
get:
|
||||
summary: List all versions of an MCP server
|
||||
description: Returns all available versions for a specific MCP server, ordered by publication date (newest first)
|
||||
parameters:
|
||||
- name: server_id
|
||||
- name: serverName
|
||||
in: path
|
||||
required: true
|
||||
description: Unique server ID (consistent across all versions)
|
||||
description: URL-encoded server name (e.g., "com.example%2Fmy-server")
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "com.example%2Fmy-server"
|
||||
responses:
|
||||
'200':
|
||||
description: A list of all versions for the server
|
||||
|
|
@ -99,6 +97,42 @@ paths:
|
|||
error:
|
||||
type: string
|
||||
example: "Server not found"
|
||||
/v0/servers/{serverName}/versions/{version}:
|
||||
get:
|
||||
summary: Get specific MCP server version
|
||||
description: Returns detailed information about a specific version of an MCP server.
|
||||
parameters:
|
||||
- name: serverName
|
||||
in: path
|
||||
required: true
|
||||
description: URL-encoded server name (e.g., "com.example%2Fmy-server")
|
||||
schema:
|
||||
type: string
|
||||
example: "com.example%2Fmy-server"
|
||||
- name: version
|
||||
in: path
|
||||
required: true
|
||||
description: URL-encoded version to retrieve (e.g., "1.0.0" or "1.0.0%2B20130313144700" for versions with build metadata)
|
||||
schema:
|
||||
type: string
|
||||
example: "1.0.0"
|
||||
responses:
|
||||
'200':
|
||||
description: Detailed server information
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerResponse'
|
||||
'404':
|
||||
description: Server or version not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: "Server not found"
|
||||
/v0/publish:
|
||||
post:
|
||||
summary: Publish MCP server (Optional)
|
||||
|
|
@ -122,7 +156,6 @@ paths:
|
|||
value:
|
||||
name: "io.modelcontextprotocol/filesystem"
|
||||
description: "Node.js server implementing Model Context Protocol (MCP) for filesystem operations."
|
||||
status: "active"
|
||||
repository:
|
||||
url: "https://github.com/modelcontextprotocol/servers"
|
||||
source: "github"
|
||||
|
|
@ -136,7 +169,7 @@ paths:
|
|||
io.modelcontextprotocol.registry/publisher-provided:
|
||||
tool: "publisher-cli"
|
||||
version: "1.2.3"
|
||||
build_info:
|
||||
buildInfo:
|
||||
commit: "abc123def456"
|
||||
timestamp: "2023-12-01T10:30:00Z"
|
||||
example_org_server:
|
||||
|
|
@ -144,7 +177,6 @@ paths:
|
|||
value:
|
||||
name: "com.example/demo-server"
|
||||
description: "Example MCP server demonstrating publisher extensions."
|
||||
status: "active"
|
||||
repository:
|
||||
url: "https://github.com/example/mcp-demo"
|
||||
source: "github"
|
||||
|
|
@ -156,22 +188,22 @@ paths:
|
|||
version: "1.0.0"
|
||||
_meta:
|
||||
io.modelcontextprotocol.registry/publisher-provided:
|
||||
contact_email: "maintainer@example.com"
|
||||
build_metadata:
|
||||
contactEmail: "maintainer@example.com"
|
||||
buildMetadata:
|
||||
commit: "abc123"
|
||||
timestamp: "2024-01-15T10:30:00Z"
|
||||
com.example:
|
||||
marketplace_icon: "https://example.com/icon.png"
|
||||
marketplaceIcon: "https://example.com/icon.png"
|
||||
category: ["software_development"]
|
||||
documentation_url: "https://docs.example.org"
|
||||
verified_publisher: true
|
||||
documentationUrl: "https://docs.example.org"
|
||||
verifiedPublisher: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully published server
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerDetail'
|
||||
$ref: '#/components/schemas/ServerResponse'
|
||||
'401':
|
||||
description: Unauthorized - Invalid or missing authentication token
|
||||
content:
|
||||
|
|
@ -250,12 +282,6 @@ components:
|
|||
type: string
|
||||
description: "Human-readable description of the server's functionality"
|
||||
example: "Node.js server implementing Model Context Protocol (MCP) for filesystem operations."
|
||||
status:
|
||||
type: string
|
||||
enum: [active, deprecated]
|
||||
default: active
|
||||
description: "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage."
|
||||
example: "active"
|
||||
repository:
|
||||
$ref: '#/components/schemas/Repository'
|
||||
version:
|
||||
|
|
@ -277,13 +303,15 @@ components:
|
|||
servers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ServerDetail'
|
||||
$ref: '#/components/schemas/ServerResponse'
|
||||
metadata:
|
||||
type: object
|
||||
properties:
|
||||
next_cursor:
|
||||
nextCursor:
|
||||
type: string
|
||||
description: Cursor for next page of results
|
||||
description: |
|
||||
Pagination cursor for retrieving the next page of results.
|
||||
Use this exact value in the `cursor` query parameter of your next request. If null or empty, there are no more results.
|
||||
count:
|
||||
type: integer
|
||||
description: Number of items in current page
|
||||
|
|
@ -491,7 +519,7 @@ components:
|
|||
type: string
|
||||
format: uri
|
||||
description: JSON Schema URI for this server.json format
|
||||
example: "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json"
|
||||
example: "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json"
|
||||
packages:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -511,43 +539,46 @@ components:
|
|||
example:
|
||||
tool: "publisher-cli"
|
||||
version: "1.2.3"
|
||||
build_info:
|
||||
buildInfo:
|
||||
commit: "abc123def456"
|
||||
timestamp: "2023-12-01T10:30:00Z"
|
||||
pipeline_id: "build-789"
|
||||
io.modelcontextprotocol.registry/official:
|
||||
type: object
|
||||
description: Registry-specific metadata managed by the MCP registry system
|
||||
required:
|
||||
- serverId
|
||||
- versionId
|
||||
- publishedAt
|
||||
- updatedAt
|
||||
- isLatest
|
||||
properties:
|
||||
serverId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Consistent ID across all versions of a server
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
versionId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Unique ID for this specific version
|
||||
example: "773f9b2e-1a47-4c8d-b5e6-2f8d9c4a7b3e"
|
||||
publishedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the server was first published to the registry
|
||||
example: "2023-12-01T10:30:00Z"
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the server entry was last updated
|
||||
example: "2023-12-01T11:00:00Z"
|
||||
isLatest:
|
||||
type: boolean
|
||||
description: Whether this is the latest version of the server
|
||||
example: true
|
||||
additionalProperties: false
|
||||
additionalProperties: true
|
||||
pipelineId: "build-789"
|
||||
|
||||
ServerResponse:
|
||||
description: API response format with separated server data and registry metadata
|
||||
type: object
|
||||
required:
|
||||
- server
|
||||
- _meta
|
||||
properties:
|
||||
server:
|
||||
$ref: '#/components/schemas/ServerDetail'
|
||||
_meta:
|
||||
type: object
|
||||
description: Registry-managed metadata
|
||||
properties:
|
||||
io.modelcontextprotocol.registry/official:
|
||||
type: object
|
||||
description: Official MCP registry metadata
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: ["active", "deprecated", "deleted"]
|
||||
description: Server lifecycle status
|
||||
example: "active"
|
||||
publishedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the server was first published to the registry
|
||||
example: "2023-12-01T10:30:00Z"
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the server entry was last updated
|
||||
example: "2023-12-01T11:00:00Z"
|
||||
isLatest:
|
||||
type: boolean
|
||||
description: Whether this is the latest version of the server
|
||||
example: true
|
||||
additionalProperties: false
|
||||
additionalProperties: true
|
||||
|
|
|
|||
|
|
@ -2,6 +2,24 @@
|
|||
|
||||
Changes to the server.json schema and format.
|
||||
|
||||
## 2025-09-29
|
||||
|
||||
### ⚠️ BREAKING CHANGES
|
||||
|
||||
#### Schema Simplification
|
||||
|
||||
Removed registry-managed fields from publisher-controlled server.json schema.
|
||||
|
||||
**Removed fields:**
|
||||
- `status` field from Server object (now managed by registry in API responses)
|
||||
- `io.modelcontextprotocol.registry/official` from `_meta` (read-only, added by registry)
|
||||
|
||||
**Migration:**
|
||||
Publishers should remove these fields from their `server.json` files. The registry will manage server status and official metadata separately.
|
||||
|
||||
### Changed
|
||||
- Schema version: `2025-09-16` → `2025-09-29`
|
||||
|
||||
## 2025-09-16
|
||||
|
||||
### ⚠️ BREAKING CHANGES
|
||||
|
|
@ -116,7 +134,7 @@ All JSON field names standardized to camelCase. **All existing `server.json` fil
|
|||
|
||||
#### Updated Schema Reference
|
||||
|
||||
🔗 **Current schema**: https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json
|
||||
🔗 **Current schema**: https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json
|
||||
|
||||
### Changed
|
||||
- Schema version: `2025-07-09` → `2025-09-16`
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Also see:
|
|||
|
||||
## Browse the Complete Schema
|
||||
|
||||
**📋 View the full specification interactively**: Open [server.schema.json](./server.schema.json) in a schema viewer like [json-schema.app](https://json-schema.app/view/%23?url=https%3A%2F%2Fstatic.modelcontextprotocol.io%2Fschemas%2F2025-09-16%2Fserver.schema.json).
|
||||
**📋 View the full specification interactively**: Open [server.schema.json](./server.schema.json) in a schema viewer like [json-schema.app](https://json-schema.app/view/%23?url=https%3A%2F%2Fstatic.modelcontextprotocol.io%2Fschemas%2F2025-09-29%2Fserver.schema.json).
|
||||
|
||||
The schema contains all field definitions, validation rules, examples, and detailed descriptions.
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ The official registry has some more restrictions on top of this. See the [offici
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.modelcontextprotocol.anonymous/brave-search",
|
||||
"description": "MCP server for Brave Search API integration",
|
||||
"status": "active",
|
||||
|
|
@ -69,7 +69,7 @@ For MCP servers located within a subdirectory of a larger repository (monorepo s
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.modelcontextprotocol/everything",
|
||||
"description": "MCP server that exercises all the features of the MCP protocol",
|
||||
"status": "active",
|
||||
|
|
@ -151,7 +151,7 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.github.modelcontextprotocol/filesystem",
|
||||
"description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.",
|
||||
"status": "active",
|
||||
|
|
@ -498,7 +498,7 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.modelcontextprotocol.anonymous/hybrid-mcp",
|
||||
"description": "MCP server available as both local package and remote service",
|
||||
"repository": {
|
||||
|
|
@ -626,7 +626,7 @@ Some CLI tools bundle an MCP server, without a standalone MCP package or a publi
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.snyk/cli-mcp",
|
||||
"description": "MCP server provided by the Snyk CLI",
|
||||
"status": "active",
|
||||
|
|
@ -661,7 +661,7 @@ For MCP servers that follow a custom installation path or are embedded in applic
|
|||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"name": "io.modelcontextprotocol.anonymous/embedded-mcp",
|
||||
"description": "MCP server embedded in a Desktop app",
|
||||
"status": "active",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||
"$id": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
||||
"title": "MCP Server Detail",
|
||||
"$ref": "#/definitions/ServerDetail",
|
||||
"definitions": {
|
||||
|
|
@ -58,12 +58,6 @@
|
|||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "deprecated", "deleted"],
|
||||
"default": "active",
|
||||
"description": "Server lifecycle status. 'deprecated' indicates the server is no longer recommended for new usage. 'deleted' indicates the server should never be installed and existing installations should be uninstalled - this is rare, and usually indicates malware or a legal takedown."
|
||||
},
|
||||
"repository": {
|
||||
"$ref": "#/definitions/Repository",
|
||||
"description": "Optional repository metadata for the MCP server source code. Recommended for transparency and security inspection."
|
||||
|
|
@ -432,7 +426,7 @@
|
|||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "JSON Schema URI for this server.json format",
|
||||
"example": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json"
|
||||
"example": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json"
|
||||
},
|
||||
"packages": {
|
||||
"type": "array",
|
||||
|
|
@ -462,11 +456,6 @@
|
|||
"type": "object",
|
||||
"description": "Publisher-provided metadata for downstream registries",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"io.modelcontextprotocol.registry/official": {
|
||||
"type": "object",
|
||||
"description": "Official MCP registry metadata (read-only, added by registry)",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ func NewNoneHandler(cfg *config.Config) *NoneHandler {
|
|||
}
|
||||
|
||||
// RegisterNoneEndpoint registers the anonymous authentication endpoint
|
||||
// WARNING: This endpoint is intended for local development and automated tests only.
|
||||
// It should NOT be enabled in production environments as it bypasses normal authentication.
|
||||
func RegisterNoneEndpoint(api huma.API, cfg *config.Config) {
|
||||
if !cfg.EnableAnonymousAuth {
|
||||
return
|
||||
|
|
@ -33,13 +35,13 @@ func RegisterNoneEndpoint(api huma.API, cfg *config.Config) {
|
|||
|
||||
handler := NewNoneHandler(cfg)
|
||||
|
||||
// Anonymous token endpoint
|
||||
// Anonymous token endpoint for development/testing only
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-anonymous-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/none",
|
||||
Summary: "Get anonymous Registry JWT",
|
||||
Description: "Get a short-lived Registry JWT token for publishing to io.modelcontextprotocol.anonymous/* namespace",
|
||||
Summary: "Get anonymous Registry JWT (Development/Testing Only)",
|
||||
Description: "Get a short-lived Registry JWT token for publishing and editing servers in the io.modelcontextprotocol.anonymous/* namespace. This endpoint is intended for local development and automated testing only.",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, _ *struct{}) (*v0.Response[auth.TokenResponse], error) {
|
||||
response, err := handler.GetAnonymousToken(ctx)
|
||||
|
|
@ -61,6 +63,10 @@ func (h *NoneHandler) GetAnonymousToken(ctx context.Context) (*auth.TokenRespons
|
|||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: "io.modelcontextprotocol.anonymous/*",
|
||||
},
|
||||
{
|
||||
Action: auth.PermissionActionEdit,
|
||||
ResourcePattern: "io.modelcontextprotocol.anonymous/*",
|
||||
},
|
||||
}
|
||||
|
||||
// Create JWT claims for anonymous user
|
||||
|
|
|
|||
|
|
@ -43,8 +43,14 @@ func TestNoneHandler_GetAnonymousToken(t *testing.T) {
|
|||
assert.Equal(t, auth.MethodNone, claims.AuthMethod)
|
||||
assert.Equal(t, "anonymous", claims.AuthMethodSubject)
|
||||
|
||||
// Check permissions
|
||||
require.Len(t, claims.Permissions, 1)
|
||||
// Check permissions - should have both publish and edit permissions
|
||||
require.Len(t, claims.Permissions, 2)
|
||||
|
||||
// Check publish permission
|
||||
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
|
||||
assert.Equal(t, "io.modelcontextprotocol.anonymous/*", claims.Permissions[0].ResourcePattern)
|
||||
|
||||
// Check edit permission
|
||||
assert.Equal(t, auth.PermissionActionEdit, claims.Permissions[1].Action)
|
||||
assert.Equal(t, "io.modelcontextprotocol.anonymous/*", claims.Permissions[1].ResourcePattern)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
|
@ -18,8 +19,9 @@ import (
|
|||
// EditServerInput represents the input for editing a server
|
||||
type EditServerInput struct {
|
||||
Authorization string `header:"Authorization" doc:"Registry JWT token with edit permissions" required:"true"`
|
||||
ServerID string `path:"server_id" doc:"Server ID (UUID)" format:"uuid"`
|
||||
Version string `query:"version" doc:"Version to edit (e.g., '1.0.0')" required:"true" example:"1.0.0"`
|
||||
ServerName string `path:"serverName" doc:"URL-encoded server name" example:"com.example%2Fmy-server"`
|
||||
Version string `path:"version" doc:"URL-encoded version to edit" example:"1.0.0"`
|
||||
Status string `query:"status" doc:"New status for the server (active, deprecated, deleted)" required:"false" enum:"active,deprecated,deleted"`
|
||||
Body apiv0.ServerJSON `body:""`
|
||||
}
|
||||
|
||||
|
|
@ -31,14 +33,14 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *
|
|||
huma.Register(api, huma.Operation{
|
||||
OperationID: "edit-server",
|
||||
Method: http.MethodPut,
|
||||
Path: "/v0/servers/{server_id}",
|
||||
Path: "/v0/servers/{serverName}/versions/{version}",
|
||||
Summary: "Edit MCP server",
|
||||
Description: "Update an existing MCP server (admin only). Requires version query parameter to specify which version to edit.",
|
||||
Description: "Update a specific version of an existing MCP server (admin only).",
|
||||
Tags: []string{"admin"},
|
||||
Security: []map[string][]string{
|
||||
{"bearer": {}},
|
||||
},
|
||||
}, func(ctx context.Context, input *EditServerInput) (*Response[apiv0.ServerJSON], error) {
|
||||
}, func(ctx context.Context, input *EditServerInput) (*Response[apiv0.ServerResponse], error) {
|
||||
// Extract bearer token
|
||||
const bearerPrefix = "Bearer "
|
||||
authHeader := input.Authorization
|
||||
|
|
@ -53,9 +55,20 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *
|
|||
return nil, huma.Error401Unauthorized("Invalid or expired Registry JWT token", err)
|
||||
}
|
||||
|
||||
// Get current server to check permissions against existing name
|
||||
currentServer, err := registry.GetByServerIDAndVersion(input.ServerID, input.Version)
|
||||
// URL-decode the server name
|
||||
serverName, err := url.PathUnescape(input.ServerName)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("Invalid server name encoding", err)
|
||||
}
|
||||
|
||||
// URL-decode the version
|
||||
version, err := url.PathUnescape(input.Version)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("Invalid version encoding", err)
|
||||
}
|
||||
|
||||
// Get current server to check permissions against existing name
|
||||
currentServer, err := registry.GetServerByNameAndVersion(ctx, serverName, version)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
return nil, huma.Error404NotFound("Server not found")
|
||||
|
|
@ -64,27 +77,42 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *
|
|||
}
|
||||
|
||||
// Verify edit permissions for this server using the existing server name
|
||||
if !jwtManager.HasPermission(currentServer.Name, auth.PermissionActionEdit, claims.Permissions) {
|
||||
if !jwtManager.HasPermission(currentServer.Server.Name, auth.PermissionActionEdit, claims.Permissions) {
|
||||
return nil, huma.Error403Forbidden("You do not have edit permissions for this server")
|
||||
}
|
||||
|
||||
// Prevent renaming servers
|
||||
if currentServer.Name != input.Body.Name {
|
||||
if currentServer.Server.Name != input.Body.Name {
|
||||
return nil, huma.Error400BadRequest("Cannot rename server")
|
||||
}
|
||||
|
||||
// Prevent undeleting servers - once deleted, they stay deleted
|
||||
if currentServer.Status == model.StatusDeleted && input.Body.Status != model.StatusDeleted {
|
||||
return nil, huma.Error400BadRequest("Cannot change status of deleted server. Deleted servers cannot be undeleted.")
|
||||
// Validate that the version in the body matches the URL parameter
|
||||
if input.Body.Version != version {
|
||||
return nil, huma.Error400BadRequest("Version in request body must match URL path parameter")
|
||||
}
|
||||
|
||||
// Edit the server using the version_id from the current server
|
||||
versionID := currentServer.GetVersionID()
|
||||
if versionID == "" {
|
||||
return nil, huma.Error500InternalServerError("Server version ID not found in metadata", nil)
|
||||
// Handle status changes with proper permission validation
|
||||
if input.Status != "" {
|
||||
newStatus := model.Status(input.Status)
|
||||
|
||||
// Prevent undeleting servers - once deleted, they stay deleted
|
||||
if currentServer.Meta.Official != nil &&
|
||||
currentServer.Meta.Official.Status == model.StatusDeleted &&
|
||||
newStatus != model.StatusDeleted {
|
||||
return nil, huma.Error400BadRequest("Cannot change status of deleted server. Deleted servers cannot be undeleted.")
|
||||
}
|
||||
|
||||
// For now, only allow status changes for admins
|
||||
// Future: Implement logic to allow server authors to change active <-> deprecated
|
||||
// but only admins can set to deleted
|
||||
}
|
||||
|
||||
updatedServer, err := registry.EditServer(versionID, input.Body)
|
||||
// Update the server using the service
|
||||
var statusPtr *string
|
||||
if input.Status != "" {
|
||||
statusPtr = &input.Status
|
||||
}
|
||||
updatedServer, err := registry.UpdateServer(ctx, serverName, version, &input.Body, statusPtr)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
return nil, huma.Error404NotFound("Server not found")
|
||||
|
|
@ -92,7 +120,7 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *
|
|||
return nil, huma.Error400BadRequest("Failed to edit server", err)
|
||||
}
|
||||
|
||||
return &Response[apiv0.ServerJSON]{
|
||||
return &Response[apiv0.ServerResponse]{
|
||||
Body: *updatedServer,
|
||||
}, nil
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,14 +2,20 @@ package v0_test
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
|
|
@ -21,313 +27,616 @@ import (
|
|||
)
|
||||
|
||||
func TestEditServerEndpoint(t *testing.T) {
|
||||
// Create registry service and insert a common test server
|
||||
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
||||
|
||||
// Publish a test server that will be used across test cases
|
||||
testServer := apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Original test server",
|
||||
Status: model.StatusActive,
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/domdomegg/test-server",
|
||||
Source: "github",
|
||||
ID: "domdomegg/test-server",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
// Create test config
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
EnableRegistryValidation: false,
|
||||
}
|
||||
published, err := registryService.Publish(testServer)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, published)
|
||||
assert.NotNil(t, published.Meta)
|
||||
assert.NotNil(t, published.Meta.Official)
|
||||
|
||||
testServerID := published.Meta.Official.ServerID
|
||||
// Create registry service and test data
|
||||
registryService := service.NewRegistryService(database.NewTestDB(t), cfg)
|
||||
|
||||
// Publish a second server for permission testing
|
||||
otherServer := apiv0.ServerJSON{
|
||||
Name: "io.github.other/test-server",
|
||||
Description: "Other test server",
|
||||
Status: model.StatusActive,
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/other/test-server",
|
||||
Source: "github",
|
||||
ID: "other/test-server",
|
||||
// Create test servers for different scenarios
|
||||
testServers := map[string]*apiv0.ServerJSON{
|
||||
"editable": {
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Server that can be edited",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/testuser/editable-server",
|
||||
Source: "github",
|
||||
ID: "testuser/editable-server",
|
||||
},
|
||||
},
|
||||
Version: "1.0.0",
|
||||
}
|
||||
otherPublished, err := registryService.Publish(otherServer)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, otherPublished)
|
||||
assert.NotNil(t, otherPublished.Meta)
|
||||
assert.NotNil(t, otherPublished.Meta.Official)
|
||||
|
||||
otherServerID := otherPublished.Meta.Official.ServerID
|
||||
|
||||
// Publish a deleted server for undelete testing
|
||||
deletedServer := apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/deleted-server",
|
||||
Description: "Deleted test server",
|
||||
Status: model.StatusDeleted,
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/domdomegg/deleted-server",
|
||||
Source: "github",
|
||||
ID: "domdomegg/deleted-server",
|
||||
"other": {
|
||||
Name: "io.github.otheruser/other-server",
|
||||
Description: "Server owned by another user",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/otheruser/other-server",
|
||||
Source: "github",
|
||||
ID: "otheruser/other-server",
|
||||
},
|
||||
},
|
||||
Version: "1.0.0",
|
||||
}
|
||||
deletedPublished, err := registryService.Publish(deletedServer)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, deletedPublished)
|
||||
assert.NotNil(t, deletedPublished.Meta)
|
||||
assert.NotNil(t, deletedPublished.Meta.Official)
|
||||
|
||||
deletedServerID := deletedPublished.Meta.Official.ServerID
|
||||
// Create the test servers
|
||||
for _, server := range testServers {
|
||||
_, err := registryService.CreateServer(context.Background(), server)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a deleted server for undelete testing
|
||||
deletedServer := &apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/deleted-server",
|
||||
Description: "Server that was deleted",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/testuser/deleted-server",
|
||||
Source: "github",
|
||||
ID: "testuser/deleted-server",
|
||||
},
|
||||
}
|
||||
_, err = registryService.CreateServer(context.Background(), deletedServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set the server to deleted status
|
||||
_, err = registryService.UpdateServer(context.Background(), deletedServer.Name, deletedServer.Version, deletedServer, stringPtr(string(model.StatusDeleted)))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a server with build metadata for URL encoding test
|
||||
buildMetadataServer := &apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/build-metadata-server",
|
||||
Description: "Server with build metadata version",
|
||||
Version: "1.0.0+20130313144700",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/testuser/build-metadata-server",
|
||||
Source: "github",
|
||||
ID: "testuser/build-metadata-server",
|
||||
},
|
||||
}
|
||||
_, err = registryService.CreateServer(context.Background(), buildMetadataServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
requestBody interface{}
|
||||
serverID string
|
||||
serverName string
|
||||
version string
|
||||
authClaims *auth.JWTClaims
|
||||
authHeader string
|
||||
requestBody apiv0.ServerJSON
|
||||
statusParam string
|
||||
expectedStatus int
|
||||
expectedError string
|
||||
checkResult func(*testing.T, *apiv0.ServerResponse)
|
||||
}{
|
||||
{
|
||||
name: "successful edit with valid token and permissions",
|
||||
authHeader: func() string {
|
||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "domdomegg",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
|
||||
},
|
||||
})
|
||||
return "Bearer " + token
|
||||
}(),
|
||||
name: "successful edit with valid permissions",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Updated test server",
|
||||
Status: model.StatusDeprecated,
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Updated server description",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/domdomegg/test-server",
|
||||
URL: "https://github.com/testuser/editable-server",
|
||||
Source: "github",
|
||||
ID: "domdomegg/test-server",
|
||||
ID: "testuser/editable-server",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: testServerID,
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusOK,
|
||||
checkResult: func(t *testing.T, resp *apiv0.ServerResponse) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Updated server description", resp.Server.Description)
|
||||
assert.Equal(t, "io.github.testuser/editable-server", resp.Server.Name)
|
||||
assert.Equal(t, "1.0.0", resp.Server.Version)
|
||||
assert.NotNil(t, resp.Meta.Official)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful edit with status change",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Server with status change",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
statusParam: "deprecated",
|
||||
expectedStatus: http.StatusOK,
|
||||
checkResult: func(t *testing.T, resp *apiv0.ServerResponse) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Server with status change", resp.Server.Description)
|
||||
assert.Equal(t, model.StatusDeprecated, resp.Meta.Official.Status)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing authorization header",
|
||||
authHeader: "",
|
||||
requestBody: apiv0.ServerJSON{},
|
||||
serverID: testServerID,
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
expectedStatus: 422,
|
||||
authHeader: "", // No auth header
|
||||
requestBody: apiv0.ServerJSON{},
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedError: "required header parameter is missing",
|
||||
},
|
||||
{
|
||||
name: "invalid authorization header format",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authHeader: "InvalidFormat token123",
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: testServerID,
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Unauthorized",
|
||||
expectedError: "Invalid Authorization header format",
|
||||
},
|
||||
{
|
||||
name: "invalid token",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authHeader: "Bearer invalid-token",
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: testServerID,
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Unauthorized",
|
||||
expectedError: "Invalid or expired Registry JWT token",
|
||||
},
|
||||
{
|
||||
name: "permission denied - no edit permissions",
|
||||
authHeader: func() string {
|
||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "domdomegg",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.domdomegg/*"},
|
||||
},
|
||||
})
|
||||
return "Bearer " + token
|
||||
}(),
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Updated test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: testServerID,
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "Forbidden",
|
||||
},
|
||||
{
|
||||
name: "permission denied - wrong resource",
|
||||
authHeader: func() string {
|
||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "domdomegg",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
|
||||
},
|
||||
})
|
||||
return "Bearer " + token
|
||||
}(),
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.other/test-server",
|
||||
Description: "Updated test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: otherServerID,
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "Forbidden",
|
||||
},
|
||||
{
|
||||
name: "server not found",
|
||||
authHeader: func() string {
|
||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "domdomegg",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
|
||||
},
|
||||
})
|
||||
return "Bearer " + token
|
||||
}(),
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/nonexistent-server",
|
||||
Description: "Updated test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: "550e8400-e29b-41d4-a716-446655440999", // Non-existent ID
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedError: "Not Found",
|
||||
},
|
||||
{
|
||||
name: "validation error - invalid server name",
|
||||
authHeader: func() string {
|
||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "domdomegg",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
},
|
||||
})
|
||||
return "Bearer " + token
|
||||
}(),
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "invalid-name-format", // Missing namespace/name format
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
serverID: testServerID,
|
||||
version: "1.0.0",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Bad Request",
|
||||
},
|
||||
{
|
||||
name: "cannot undelete server",
|
||||
authHeader: func() string {
|
||||
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
|
||||
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "domdomegg",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
},
|
||||
})
|
||||
return "Bearer " + token
|
||||
}(),
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/deleted-server",
|
||||
Description: "Trying to undelete server",
|
||||
Status: model.StatusActive,
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/domdomegg/deleted-server",
|
||||
Source: "github",
|
||||
ID: "domdomegg/deleted-server",
|
||||
name: "permission denied - no edit permissions",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"}, // Only publish, not edit
|
||||
},
|
||||
Version: "1.0.1",
|
||||
},
|
||||
serverID: deletedServerID,
|
||||
version: "1.0.0",
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Updated test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "You do not have edit permissions",
|
||||
},
|
||||
{
|
||||
name: "permission denied - wrong namespace",
|
||||
serverName: "io.github.otheruser/other-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"}, // Wrong namespace
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.otheruser/other-server",
|
||||
Description: "Updated test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "You do not have edit permissions",
|
||||
},
|
||||
{
|
||||
name: "server not found",
|
||||
serverName: "io.github.testuser/non-existent",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/non-existent",
|
||||
Description: "Non-existent server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedError: "Server not found",
|
||||
},
|
||||
{
|
||||
name: "attempt to rename server should fail",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/renamed-server", // Different name
|
||||
Description: "Trying to rename server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Cannot rename server",
|
||||
},
|
||||
{
|
||||
name: "version in body must match URL parameter",
|
||||
serverName: "io.github.testuser/editable-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/editable-server",
|
||||
Description: "Version mismatch test",
|
||||
Version: "2.0.0", // Different version from URL
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Version in request body must match URL path parameter",
|
||||
},
|
||||
{
|
||||
name: "attempt to undelete server should fail",
|
||||
serverName: "io.github.testuser/deleted-server",
|
||||
version: "1.0.0",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/deleted-server",
|
||||
Description: "Trying to undelete server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
statusParam: "active", // Trying to change from deleted to active
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Cannot change status of deleted server",
|
||||
},
|
||||
{
|
||||
name: "successful edit of version with build metadata (URL encoded)",
|
||||
serverName: "io.github.testuser/build-metadata-server",
|
||||
version: "1.0.0+20130313144700",
|
||||
authClaims: &auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
},
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.testuser/build-metadata-server",
|
||||
Description: "Updated server with build metadata",
|
||||
Version: "1.0.0+20130313144700",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/testuser/build-metadata-server",
|
||||
Source: "github",
|
||||
ID: "testuser/build-metadata-server",
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
checkResult: func(t *testing.T, resp *apiv0.ServerResponse) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Updated server with build metadata", resp.Server.Description)
|
||||
assert.Equal(t, "io.github.testuser/build-metadata-server", resp.Server.Name)
|
||||
assert.Equal(t, "1.0.0+20130313144700", resp.Server.Version)
|
||||
assert.NotNil(t, resp.Meta.Official)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create Huma API
|
||||
mux := http.NewServeMux()
|
||||
humaConfig := huma.DefaultConfig("Test API", "1.0.0")
|
||||
api := humago.New(mux, humaConfig)
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register edit endpoints
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c",
|
||||
}
|
||||
v0.RegisterEditEndpoints(api, registryService, cfg)
|
||||
|
||||
// Create request body
|
||||
var requestBody []byte
|
||||
var err error
|
||||
if str, ok := tc.requestBody.(string); ok {
|
||||
requestBody = []byte(str)
|
||||
} else {
|
||||
requestBody, err = json.Marshal(tc.requestBody)
|
||||
assert.NoError(t, err)
|
||||
requestBody, err := json.Marshal(tc.requestBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create request URL with proper encoding
|
||||
encodedServerName := url.PathEscape(tc.serverName)
|
||||
encodedVersion := url.PathEscape(tc.version)
|
||||
requestURL := "/v0/servers/" + encodedServerName + "/versions/" + encodedVersion
|
||||
if tc.statusParam != "" {
|
||||
requestURL += "?status=" + tc.statusParam
|
||||
}
|
||||
|
||||
// Create request
|
||||
url := "/v0/servers/" + tc.serverID
|
||||
if tc.version != "" {
|
||||
url += "?version=" + tc.version
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, url, bytes.NewReader(requestBody))
|
||||
req := httptest.NewRequest(http.MethodPut, requestURL, bytes.NewReader(requestBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Set authorization header
|
||||
if tc.authHeader != "" {
|
||||
req.Header.Set("Authorization", tc.authHeader)
|
||||
} else if tc.authClaims != nil {
|
||||
// Generate valid JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(context.Background(), *tc.authClaims)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenResponse.RegistryToken)
|
||||
}
|
||||
|
||||
// Create response recorder
|
||||
// Create response recorder and execute request
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call the endpoint
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Check status code
|
||||
// Check response
|
||||
assert.Equal(t, tc.expectedStatus, w.Code)
|
||||
|
||||
// Check error message if expected
|
||||
if tc.expectedError != "" {
|
||||
assert.Contains(t, w.Body.String(), tc.expectedError)
|
||||
}
|
||||
|
||||
// No mock assertions needed with real service
|
||||
if tc.expectedStatus == http.StatusOK && tc.checkResult != nil {
|
||||
var response apiv0.ServerResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
tc.checkResult(t, &response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditServerEndpointEdgeCases(t *testing.T) {
|
||||
// Create test config
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
EnableRegistryValidation: false,
|
||||
}
|
||||
|
||||
// Create registry service
|
||||
registryService := service.NewRegistryService(database.NewTestDB(t), cfg)
|
||||
|
||||
// Setup test servers with different characteristics
|
||||
testServers := []struct {
|
||||
name string
|
||||
version string
|
||||
status model.Status
|
||||
}{
|
||||
{"com.example/active-server", "1.0.0", model.StatusActive},
|
||||
{"com.example/deprecated-server", "1.0.0", model.StatusDeprecated},
|
||||
{"com.example/multi-version-server", "1.0.0", model.StatusActive},
|
||||
{"com.example/multi-version-server", "2.0.0", model.StatusActive},
|
||||
}
|
||||
|
||||
for _, server := range testServers {
|
||||
_, err := registryService.CreateServer(context.Background(), &apiv0.ServerJSON{
|
||||
Name: server.name,
|
||||
Description: "Test server for editing",
|
||||
Version: server.version,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set specific status if not active
|
||||
if server.status != model.StatusActive {
|
||||
_, err = registryService.UpdateServer(context.Background(), server.name, server.version, &apiv0.ServerJSON{
|
||||
Name: server.name,
|
||||
Description: "Test server for editing",
|
||||
Version: server.version,
|
||||
}, stringPtr(string(server.status)))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
v0.RegisterEditEndpoints(api, registryService, cfg)
|
||||
|
||||
t.Run("status transitions", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverName string
|
||||
version string
|
||||
fromStatus string
|
||||
toStatus string
|
||||
expectedStatus int
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "active to deprecated",
|
||||
serverName: "com.example/active-server",
|
||||
version: "1.0.0",
|
||||
toStatus: "deprecated",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "deprecated to active",
|
||||
serverName: "com.example/deprecated-server",
|
||||
version: "1.0.0",
|
||||
toStatus: "active",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "active to deleted",
|
||||
serverName: "com.example/active-server",
|
||||
version: "1.0.0",
|
||||
toStatus: "deleted",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
serverName: "com.example/active-server",
|
||||
version: "1.0.0",
|
||||
toStatus: "invalid_status",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedError: "validation failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
requestBody := apiv0.ServerJSON{
|
||||
Name: tt.serverName,
|
||||
Description: "Status transition test",
|
||||
Version: tt.version,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedName := url.PathEscape(tt.serverName)
|
||||
requestURL := "/v0/servers/" + encodedName + "/versions/" + tt.version + "?status=" + tt.toStatus
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, requestURL, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Generate admin token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(context.Background(), auth.JWTClaims{
|
||||
AuthMethod: auth.MethodNone,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenResponse.RegistryToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Contains(t, w.Body.String(), tt.expectedError)
|
||||
}
|
||||
|
||||
if tt.expectedStatus == http.StatusOK {
|
||||
var response apiv0.ServerResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.Status(tt.toStatus), response.Meta.Official.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("URL encoding edge cases", func(t *testing.T) {
|
||||
// Create server with special characters
|
||||
specialServerName := "io.dots.and-dashes/server_with_underscores"
|
||||
_, err := registryService.CreateServer(context.Background(), &apiv0.ServerJSON{
|
||||
Name: specialServerName,
|
||||
Description: "Server with special characters",
|
||||
Version: "1.0.0",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
requestBody := apiv0.ServerJSON{
|
||||
Name: specialServerName,
|
||||
Description: "Updated server with special chars",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedName := url.PathEscape(specialServerName)
|
||||
requestURL := "/v0/servers/" + encodedName + "/versions/1.0.0"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, requestURL, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Generate admin token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(context.Background(), auth.JWTClaims{
|
||||
AuthMethod: auth.MethodNone,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenResponse.RegistryToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response apiv0.ServerResponse
|
||||
err = json.NewDecoder(w.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, specialServerName, response.Server.Name)
|
||||
assert.Equal(t, "Updated server with special chars", response.Server.Description)
|
||||
})
|
||||
|
||||
t.Run("version-specific editing", func(t *testing.T) {
|
||||
// Test editing a specific version of a multi-version server
|
||||
requestBody := apiv0.ServerJSON{
|
||||
Name: "com.example/multi-version-server",
|
||||
Description: "Updated v1.0.0 specifically",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedName := url.PathEscape("com.example/multi-version-server")
|
||||
requestURL := "/v0/servers/" + encodedName + "/versions/1.0.0"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, requestURL, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Generate admin token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(context.Background(), auth.JWTClaims{
|
||||
AuthMethod: auth.MethodNone,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenResponse.RegistryToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response apiv0.ServerResponse
|
||||
err = json.NewDecoder(w.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated v1.0.0 specifically", response.Server.Description)
|
||||
assert.Equal(t, "1.0.0", response.Server.Version)
|
||||
|
||||
// Verify the other version wasn't affected
|
||||
otherVersion, err := registryService.GetServerByNameAndVersion(context.Background(), "com.example/multi-version-server", "2.0.0")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, "Updated v1.0.0 specifically", otherVersion.Server.Description)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue